diff --git a/Cargo.lock b/Cargo.lock index 942d3cab..e42ee81a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -285,6 +285,7 @@ dependencies = [ "serde_yaml_ng", "sha2", "tempfile", + "thiserror", "tokio", "tokio-test", "toml", diff --git a/Cargo.toml b/Cargo.toml index 142248c6..acc01354 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -40,6 +40,7 @@ ignore = "0.4" # Error handling anyhow = "1" +thiserror = "2" # Config toml = "0.8" diff --git a/src/commands/review.rs b/src/commands/review.rs index 97bddea0..11403762 100644 --- a/src/commands/review.rs +++ b/src/commands/review.rs @@ -132,7 +132,7 @@ pub async fn execute_review( if progress.is_enabled() { progress.error(&e.to_string(), "calling_llm"); } - return Err(e); + return Err(e.into()); } }; @@ -189,23 +189,23 @@ fn get_diff(opts: &ReviewOptions, _config: &Config) -> Result { .with_context(|| format!("failed to read diff file: {diff_file}")); } if let Some(ref commit_ref) = opts.commit { - return git::get_commit_diff(commit_ref); + return git::get_commit_diff(commit_ref).map_err(Into::into); } if opts.staged { - git::get_staged_diff() + git::get_staged_diff().map_err(Into::into) } else if opts.unpushed { - git::get_unpushed_diff() + git::get_unpushed_diff().map_err(Into::into) } else if let Some(ref base) = opts.base { - git::get_branch_diff(base) + git::get_branch_diff(base).map_err(Into::into) } else if opts.unstaged { - git::get_unstaged_diff() + git::get_unstaged_diff().map_err(Into::into) } else { // Default: try staged first, fall back to unpushed, then unstaged match git::get_staged_diff() { Ok(d) if !d.trim().is_empty() => Ok(d), _ => match git::get_unpushed_diff() { Ok(d) if !d.trim().is_empty() => Ok(d), - _ => git::get_unstaged_diff(), + _ => git::get_unstaged_diff().map_err(Into::into), }, } } diff --git a/src/config/loader.rs b/src/config/loader.rs index c6b22352..ee5a2042 100644 --- a/src/config/loader.rs +++ b/src/config/loader.rs @@ -1,6 +1,6 @@ use std::path::{Path, PathBuf}; -use anyhow::{Context, Result}; +use crate::error::CoraError; use tracing::debug; use crate::config::providers::{PRESETS, detected_presets}; @@ -24,7 +24,7 @@ const MIGRATION_MARKER: &str = ".migrated"; /// Locate the `.cora.yaml` config by walking parent directories from `start`. /// Returns the path and parsed content, or `None` if not found. -pub fn find_cora_file(start: &Path) -> Result> { +pub fn find_cora_file(start: &Path) -> std::result::Result, CoraError> { let mut dir = if start.is_file() { start.parent().unwrap_or(start).to_path_buf() } else { @@ -36,14 +36,13 @@ pub fn find_cora_file(start: &Path) -> Result> { if candidate.is_file() { debug!(path = %candidate.display(), "found .cora.yaml"); let content = std::fs::read_to_string(&candidate) - .with_context(|| format!("failed to read {}", candidate.display()))?; + .map_err(|e| CoraError::ConfigRead(format!("{}: {}", candidate.display(), e)))?; let cora = CoraFile::from_str(&content).map_err(|e| { - let msg = e.to_string(); - anyhow::anyhow!( + CoraError::ConfigParse(format!( "{}\n → file: {}\n → hint: check for syntax errors (indentation, colons, trailing spaces)", - msg, + e, candidate.display() - ) + )) })?; return Ok(Some((candidate, cora))); } @@ -57,14 +56,14 @@ pub fn find_cora_file(start: &Path) -> Result> { /// Load the global config from `~/.cora/config.yaml`. /// Returns `None` if the file doesn't exist or can't be parsed. -fn load_global_config() -> Result> { +fn load_global_config() -> std::result::Result, CoraError> { let dir = cora_dir()?; let path = dir.join(GLOBAL_CONFIG_FILENAME); if !path.is_file() { return Ok(None); } let content = std::fs::read_to_string(&path) - .with_context(|| format!("failed to read {}", path.display()))?; + .map_err(|e| CoraError::ConfigRead(format!("{}: {}", path.display(), e)))?; let cora = CoraFile::from_str(&content)?; debug!(path = %path.display(), "loaded global config"); Ok(Some(cora)) @@ -232,7 +231,7 @@ pub fn load_config( cli_base_url: Option<&str>, cli_format: Option<&str>, no_color: bool, -) -> Result { +) -> std::result::Result { let mut config = Config::default(); // Run migration silently on first access @@ -247,15 +246,13 @@ pub fn load_config( if let Some(path) = cli_config_path { let path = Path::new(path); let content = std::fs::read_to_string(path) - .with_context(|| format!("failed to read config at {}", path.display()))?; + .map_err(|e| CoraError::ConfigRead(format!("{}: {}", path.display(), e)))?; let cora = CoraFile::from_str(&content).map_err(|e| { - let msg = e.to_string(); - // Enhance error message with path info and hint about malformed YAML - anyhow::anyhow!( + CoraError::ConfigParse(format!( "{}\n → file: {}\n → hint: check for syntax errors (indentation, colons, trailing spaces)", - msg, + e, path.display() - ) + )) })?; cora.merge_into(&mut config); debug!(path = %path.display(), "loaded explicit config"); @@ -291,7 +288,10 @@ pub fn load_config( /// /// If none of those are set, auto-detect from known provider env vars (`OPENAI_API_KEY`, etc.) /// and configure `provider/model/base_url` from the matching preset. -pub fn build_llm_config(config: &Config, cli_api_key: Option<&str>) -> Result { +pub fn build_llm_config( + config: &Config, + cli_api_key: Option<&str>, +) -> std::result::Result { // Resolve the API key and optional auto-detected preset in one pass. let (api_key, auto_preset) = if let Some(key) = cli_api_key { (key.to_string(), None) @@ -303,16 +303,11 @@ pub fn build_llm_config(config: &Config, cli_api_key: Option<&str>) -> Result = PRESETS + let _available: Vec = PRESETS .iter() .map(|p| format!(" {} (set {})", p.name, p.env_key)) .collect(); - anyhow::bail!( - "no API key found.\n\ - Set one of the following environment variables, pass --api-key, or run `cora auth login`:\n\ - \n CORA_API_KEY (generic, used with current config)\n{}", - available.join("\n") - ); + return Err(CoraError::NoApiKey); } // Use the first detected provider @@ -366,13 +361,14 @@ pub fn build_llm_config(config: &Config, cli_api_key: Option<&str>) -> Result Result { - let home = dirs::home_dir().context("cannot determine home directory")?; +pub fn cora_dir() -> std::result::Result { + let home = dirs::home_dir() + .ok_or_else(|| CoraError::ConfigRead("cannot determine home directory".into()))?; Ok(home.join(".cora")) } /// Read the stored API key from ~/.cora/auth.toml. -pub fn load_api_key_from_auth_file() -> Result> { +pub fn load_api_key_from_auth_file() -> std::result::Result, CoraError> { let dir = cora_dir()?; let path = dir.join(AUTH_FILENAME); if !path.is_file() { @@ -396,12 +392,12 @@ pub fn load_api_key_from_auth_file() -> Result> { } let content = std::fs::read_to_string(&path) - .with_context(|| format!("failed to read {}", path.display()))?; + .map_err(|e| CoraError::ConfigRead(format!("{}: {}", path.display(), e)))?; // Simple TOML: expect `[auth]\napi_key = "..."` or just `api_key = "..."` let value: toml::Table = content .parse::() - .context("auth config file is not valid TOML")?; + .map_err(|e| CoraError::AuthError(format!("invalid TOML: {}", e)))?; let key = value .get("auth") @@ -419,9 +415,9 @@ pub fn load_api_key_from_auth_file() -> Result> { } /// Save an API key to ~/.cora/auth.toml. -pub fn save_api_key(key: &str) -> Result<()> { +pub fn save_api_key(key: &str) -> std::result::Result<(), CoraError> { let dir = cora_dir()?; - std::fs::create_dir_all(&dir).with_context(|| format!("failed to create {}", dir.display()))?; + std::fs::create_dir_all(&dir).map_err(|e| CoraError::AuthError(e.to_string()))?; let path = dir.join(AUTH_FILENAME); let mut table = toml::Table::new(); @@ -429,7 +425,7 @@ pub fn save_api_key(key: &str) -> Result<()> { let content = table.to_string(); std::fs::write(&path, content) - .with_context(|| format!("failed to write {}", path.display()))?; + .map_err(|e| CoraError::AuthError(format!("{}: {}", path.display(), e)))?; debug!(path = %path.display(), "saved API key"); @@ -445,19 +441,19 @@ pub fn save_api_key(key: &str) -> Result<()> { } /// Remove the stored API key from ~/.cora/auth.toml. -pub fn remove_api_key() -> Result<()> { +pub fn remove_api_key() -> std::result::Result<(), CoraError> { let dir = cora_dir()?; let path = dir.join(AUTH_FILENAME); if path.is_file() { std::fs::remove_file(&path) - .with_context(|| format!("failed to remove {}", path.display()))?; + .map_err(|e| CoraError::AuthError(format!("{}: {}", path.display(), e)))?; debug!("removed API key file"); } Ok(()) } /// Check the auth status: whether an API key is available. -pub fn auth_status() -> Result { +pub fn auth_status() -> std::result::Result { if std::env::var("CORA_API_KEY").is_ok() { Ok(AuthStatus { source: "env var CORA_API_KEY".to_string(), diff --git a/src/config/schema.rs b/src/config/schema.rs index 8cf1c454..f4a11ffa 100644 --- a/src/config/schema.rs +++ b/src/config/schema.rs @@ -1,4 +1,4 @@ -use anyhow::{Context, Result}; +use crate::error::CoraError; use serde::{Deserialize, Serialize}; use crate::engine::Severity; @@ -214,8 +214,8 @@ pub struct LlmSection { } impl CoraFile { - pub fn from_str(content: &str) -> Result { - serde_yaml_ng::from_str(content).context("failed to parse .cora.yaml") + pub fn from_str(content: &str) -> std::result::Result { + serde_yaml_ng::from_str(content).map_err(|e| CoraError::ConfigParse(e.to_string())) } /// Merge this file config into a `Config`, overwriting only fields that are present. @@ -870,7 +870,7 @@ provider: assert!(result.is_err(), "malformed YAML should return an error"); let err = result.unwrap_err().to_string(); assert!( - err.contains("failed to parse .cora.yaml"), + err.contains("config parse error"), "error message should mention parse failure: {err}" ); } diff --git a/src/engine/cache.rs b/src/engine/cache.rs index 78197fa1..8f0926e1 100644 --- a/src/engine/cache.rs +++ b/src/engine/cache.rs @@ -1,4 +1,4 @@ -use anyhow::{Context, Result}; +use crate::error::CoraError; use sha2::{Digest, Sha256}; use std::path::PathBuf; use std::time::{SystemTime, UNIX_EPOCH}; @@ -7,8 +7,9 @@ use tracing::debug; use crate::engine::types::ReviewResponse; /// Get the cache directory: ~/.cache/cora/reviews/ -fn cache_dir() -> Result { - let home = dirs::home_dir().context("cannot determine home directory")?; +fn cache_dir() -> std::result::Result { + let home = dirs::home_dir() + .ok_or_else(|| CoraError::ConfigRead("cannot determine home directory".into()))?; Ok(home.join(".cache").join("cora").join("reviews")) } @@ -77,10 +78,9 @@ pub fn save_cached_review( model: &str, temperature: f32, response: &ReviewResponse, -) -> Result<()> { +) -> std::result::Result<(), CoraError> { let dir = cache_dir()?; - std::fs::create_dir_all(&dir) - .with_context(|| format!("failed to create cache dir {}", dir.display()))?; + std::fs::create_dir_all(&dir).map_err(CoraError::CacheIo)?; let hash = cache_key(diff, model, temperature); let path = dir.join(format!("{hash}.json")); @@ -95,11 +95,10 @@ pub fn save_cached_review( timestamp: now, }; - let json = - serde_json::to_string_pretty(&cached).context("failed to serialize cached review")?; + let json = serde_json::to_string_pretty(&cached) + .map_err(|e| CoraError::CacheSerialize(e.to_string()))?; - std::fs::write(&path, json) - .with_context(|| format!("failed to write cache to {}", path.display()))?; + std::fs::write(&path, json).map_err(CoraError::CacheIo)?; debug!(hash = %hash, "saved review to cache"); Ok(()) diff --git a/src/engine/llm.rs b/src/engine/llm.rs index ade84413..b3ab8a0d 100644 --- a/src/engine/llm.rs +++ b/src/engine/llm.rs @@ -1,4 +1,4 @@ -use anyhow::{Context, Result}; +use crate::error::CoraError; use indicatif::{ProgressBar, ProgressStyle}; use serde::{Deserialize, Serialize}; use serde_json::Value; @@ -169,7 +169,7 @@ async fn chat_completion( user_message: &str, spinner: Option<&ProgressBar>, response_format: &str, -) -> Result { +) -> std::result::Result { let client = shared_client(); let url = format!("{}/chat/completions", config.base_url.trim_end_matches('/')); @@ -212,24 +212,24 @@ async fn chat_completion( .timeout(std::time::Duration::from_secs(config.timeout)) .send() .await - .context("LLM API request failed (or timed out)")?; + .map_err(CoraError::LlmRequest)?; let status = response.status(); - let body = response - .text() - .await - .context("failed to read LLM response body")?; + let body = response.text().await.map_err(CoraError::LlmRequest)?; if !status.is_success() { - anyhow::bail!("LLM API returned status {status}: {body}"); + return Err(CoraError::LlmStatus { + status: status.as_u16(), + body, + }); } if let Some(sp) = spinner { sp.set_message("Parsing response…"); } - let parsed: ChatResponse = serde_json::from_str(&body) - .context(format!("failed to parse LLM JSON response: {body}"))?; + let parsed: ChatResponse = + serde_json::from_str(&body).map_err(|e| CoraError::LlmParse(format!("{e}: {body}")))?; let content = parsed .choices @@ -265,7 +265,7 @@ pub async fn review_diff( response_format: &str, system_prompt_override: Option<&str>, quiet: bool, -) -> Result { +) -> std::result::Result { let spinner = if quiet { None } else { @@ -343,7 +343,7 @@ pub async fn review_diff_stream( rules: &[String], response_format: &str, system_prompt_override: Option<&str>, -) -> Result { +) -> std::result::Result { let user_prompt = build_review_prompt(diff, focus, rules); let system_prompt = system_prompt_override.unwrap_or(REVIEW_SYSTEM_PROMPT); @@ -373,7 +373,7 @@ async fn chat_completion_stream( system_prompt: &str, user_message: &str, response_format: &str, -) -> Result { +) -> std::result::Result { use futures_util::StreamExt; use std::io::Write; @@ -405,12 +405,15 @@ async fn chat_completion_stream( .timeout(std::time::Duration::from_secs(config.timeout)) .send() .await - .context("LLM API streaming request failed (or timed out)")?; + .map_err(CoraError::LlmRequest)?; let status = response.status(); if !status.is_success() { let body = response.text().await.unwrap_or_default(); - anyhow::bail!("LLM API returned status {status}: {body}"); + return Err(CoraError::LlmStatus { + status: status.as_u16(), + body, + }); } let mut stream = response.bytes_stream(); @@ -420,7 +423,7 @@ async fn chat_completion_stream( let mut accumulated = String::new(); while let Some(chunk_result) = stream.next().await { - let chunk = chunk_result.context("error reading stream chunk")?; + let chunk = chunk_result.map_err(|e| CoraError::LlmStream(e.to_string()))?; let chunk_str = String::from_utf8_lossy(&chunk); // Process the chunk character by character to handle line boundaries @@ -504,7 +507,7 @@ pub async fn scan_files( rules: &[String], response_format: &str, system_prompt_override: Option<&str>, -) -> Result<(Vec, Option, Option)> { +) -> std::result::Result<(Vec, Option, Option), CoraError> { let spinner = create_spinner("Scanning files…"); let system_prompt = system_prompt_override.unwrap_or(SCAN_SYSTEM_PROMPT); @@ -616,9 +619,10 @@ fn build_review_prompt(diff: &str, focus: &[String], rules: &[String]) -> String /// Parse the LLM response into review issues. /// Handles: raw JSON array, JSON wrapped in markdown fences, array and summary format. +#[allow(clippy::type_complexity)] pub(crate) fn parse_review_response( raw: &str, -) -> Result<(Vec, String, Option)> { +) -> std::result::Result<(Vec, String, Option), CoraError> { let (json_str, summary) = extract_json_and_summary(raw); // Strip markdown code fences if present @@ -627,24 +631,25 @@ pub(crate) fn parse_review_response( // Repair common LLM JSON mistakes before strict parse let json_str = repair_json_string(&json_str); - let issues: Vec = serde_json::from_str(&json_str) - .context("LLM response is not valid JSON array of issues")?; + let issues: Vec = + serde_json::from_str(&json_str).map_err(|e| CoraError::LlmParse(e.to_string()))?; Ok((issues, summary, None)) } /// Parse the LLM response for scan mode. +#[allow(clippy::type_complexity)] pub(crate) fn parse_scan_response( raw: &str, -) -> Result<(Vec, Option, Option)> { +) -> std::result::Result<(Vec, Option, Option), CoraError> { let (json_str, summary) = extract_json_and_summary(raw); let json_str = strip_code_fences(&json_str); // Repair common LLM JSON mistakes before strict parse let json_str = repair_json_string(&json_str); - let issues: Vec = serde_json::from_str(&json_str) - .context("LLM scan response is not valid JSON array of issues")?; + let issues: Vec = + serde_json::from_str(&json_str).map_err(|e| CoraError::LlmParse(e.to_string()))?; let summary = if summary.is_empty() { None diff --git a/src/engine/review.rs b/src/engine/review.rs index 855d48f4..c3507bdf 100644 --- a/src/engine/review.rs +++ b/src/engine/review.rs @@ -1,4 +1,4 @@ -use anyhow::Result; +use crate::error::CoraError; use tracing::{debug, instrument}; use crate::config::schema::Config; @@ -60,7 +60,7 @@ pub async fn review_diff_with_cache( stream: bool, use_cache: bool, quiet: bool, -) -> Result { +) -> std::result::Result { review_diff_inner(config, llm_config, diff, stream, use_cache, quiet).await } @@ -71,7 +71,7 @@ async fn review_diff_inner( stream: bool, use_cache: bool, quiet: bool, -) -> Result { +) -> std::result::Result { debug!( diff_len = diff.len(), stream = stream, diff --git a/src/engine/scanner.rs b/src/engine/scanner.rs index d944aadb..ea0bc8cb 100644 --- a/src/engine/scanner.rs +++ b/src/engine/scanner.rs @@ -1,7 +1,7 @@ use std::collections::BTreeSet; use std::path::Path; -use anyhow::Result; +use crate::error::CoraError; use glob::Pattern; use ignore::WalkBuilder; use indicatif::{ProgressBar, ProgressStyle}; @@ -36,7 +36,7 @@ pub fn walk_project( include_patterns: &[String], exclude_patterns: &[String], extra_extensions: &[String], -) -> Result> { +) -> std::result::Result, CoraError> { debug!( root = %root.display(), "walking project directory" diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 00000000..19bf53f3 --- /dev/null +++ b/src/error.rs @@ -0,0 +1,87 @@ +use thiserror::Error; + +/// Typed error enum for the cora engine layer. +/// +/// Engine functions return `Result`. The CLI/command layer +/// continues to use `anyhow::Result` and converts via `?` (automatic +/// `From for anyhow::Error` provided by `thiserror` + anyhow interop). +#[derive(Debug, Error)] +#[allow(dead_code)] +pub enum CoraError { + /// HTTP request to LLM API failed (network, DNS, timeout, etc.) + #[error("LLM API request failed: {0}")] + LlmRequest(#[from] reqwest::Error), + + /// LLM API returned a non-2xx status code. + #[error("LLM API returned status {status}: {body}")] + LlmStatus { status: u16, body: String }, + + /// Failed to parse LLM JSON response. + #[error("failed to parse LLM JSON response: {0}")] + LlmParse(String), + + /// Error during streaming. + #[error("streaming error: {0}")] + LlmStream(String), + + /// JSON repair still failed after fix attempt. + #[error("JSON repair failed: {0}")] + LlmJsonRepair(String), + + /// YAML config parse error. + #[error("config parse error: {0}")] + ConfigParse(String), + + /// Config file read error. + #[error("config read error: {0}")] + ConfigRead(String), + + /// No API key configured. + #[error("no API key found")] + NoApiKey, + + /// Diff exceeds maximum allowed size. + #[error("diff too large ({actual} chars, max {max})")] + DiffTooLarge { actual: usize, max: usize }, + + /// Invalid git ref (shell metacharacters or path traversal). + #[error("invalid ref: {0}")] + InvalidRef(String), + + /// Git command execution failed. + #[error("git command `{command}` failed: {stderr}")] + GitCommand { command: String, stderr: String }, + + /// Not inside a git repository. + #[error("not inside a git repository")] + NotInGitRepo, + + /// HEAD is detached (cannot determine branch name). + #[error("HEAD is detached — cannot determine branch name")] + HeadDetached, + + /// Cache I/O error (read/write filesystem operations). + #[error("cache I/O error: {0}")] + CacheIo(#[from] std::io::Error), + + /// JSON serialization of cache failed. + #[error("cache serialize error: {0}")] + CacheSerialize(String), + + /// File scanner error. + #[error("scanner error: {0}")] + ScannerError(String), + + /// Auth file read/write error. + #[error("auth error: {0}")] + AuthError(String), + + /// Hook install/uninstall error. + #[error("hook error: {0}")] + HookError(String), + + /// LLM request timed out. + #[error("LLM request timed out: {0}")] + Timeout(String), +} +// test comment for cora review diff --git a/src/git/diff.rs b/src/git/diff.rs index 4e1dd0e8..af90e61c 100644 --- a/src/git/diff.rs +++ b/src/git/diff.rs @@ -1,7 +1,7 @@ use std::path::Path; use std::process::Command; -use anyhow::{Context, Result}; +use crate::error::CoraError; use git2::Repository; /// Characters that should not appear in git refs passed to our diff commands. @@ -13,7 +13,7 @@ const DANGEROUS_REF_CHARS: &[char] = &[ /// Validate a git ref string for safety before passing it to git commands. /// /// Rejects refs containing shell metacharacters or path traversal sequences (..). -fn validate_ref(ref_str: &str) -> Result<()> { +fn validate_ref(ref_str: &str) -> std::result::Result<(), CoraError> { if ref_str.contains("..") { // ".." is only allowed in the middle of range expressions like "HEAD..HEAD" or "main...HEAD" // But we need to be careful about path traversal. Allow it only for git range syntax. @@ -22,7 +22,9 @@ fn validate_ref(ref_str: &str) -> Result<()> { // Since git refs shouldn't contain path components with "..", we reject refs // that start with ".." or contain "/.." or ".." at the end. if ref_str.starts_with("..") || ref_str.contains("/..") || ref_str.ends_with("..") { - anyhow::bail!("invalid ref '{ref_str}': contains path traversal sequence '..'"); + return Err(CoraError::InvalidRef(format!( + "invalid ref '{ref_str}': contains path traversal sequence '..'" + ))); } } @@ -31,51 +33,62 @@ fn validate_ref(ref_str: &str) -> Result<()> { .chars() .filter(|c| DANGEROUS_REF_CHARS.contains(c)) .collect(); - anyhow::bail!("invalid ref '{ref_str}': contains unsafe characters: '{found}'"); + return Err(CoraError::InvalidRef(format!( + "invalid ref '{ref_str}': contains unsafe characters: '{found}'" + ))); } Ok(()) } /// Open the git repository at or above the current working directory. -pub fn open_repo() -> Result { - Repository::discover(std::env::current_dir()?).context("not inside a git repository") +pub fn open_repo() -> std::result::Result { + Repository::discover(std::env::current_dir()?).map_err(|_| CoraError::NotInGitRepo) } /// Run a git command and return its stdout as a string. -fn git_cmd(args: &[&str]) -> Result { +fn git_cmd(args: &[&str]) -> std::result::Result { let output = Command::new("git") .args(args) .output() - .context("failed to execute git command")?; + .map_err(|e| CoraError::GitCommand { + command: args.join(" "), + stderr: e.to_string(), + })?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); - anyhow::bail!("git command failed: {stderr}"); + return Err(CoraError::GitCommand { + command: args.join(" "), + stderr: stderr.to_string(), + }); } - String::from_utf8(output.stdout).context("git output is not valid UTF-8") + String::from_utf8(output.stdout).map_err(|e| CoraError::GitCommand { + command: args.join(" "), + stderr: e.to_string(), + }) } /// Get the diff of currently staged changes (git diff --cached). -pub fn get_staged_diff() -> Result { +pub fn get_staged_diff() -> std::result::Result { git_cmd(&["diff", "--cached"]) } /// Get the diff of unstaged changes (working tree vs index). -pub fn get_unstaged_diff() -> Result { +pub fn get_unstaged_diff() -> std::result::Result { git_cmd(&["diff"]) } /// Get the diff between the current branch and `base_branch`. -pub fn get_branch_diff(base_branch: &str) -> Result { +pub fn get_branch_diff(base_branch: &str) -> std::result::Result { validate_ref(base_branch)?; let arg = format!("{base_branch}...HEAD"); git_cmd(&["diff", &arg]) } /// Get the diff of unpushed commits (HEAD vs @{u}). -pub fn get_unpushed_diff() -> Result { +pub fn get_unpushed_diff() -> std::result::Result { git_cmd(&["log", "-p", "@{u}..HEAD"]) } @@ -84,7 +97,7 @@ pub fn get_unpushed_diff() -> Result { /// - If the ref contains `..` (e.g. `HEAD~3..HEAD`), uses `git diff `. /// - Otherwise (e.g. `HEAD`, `abc123`), uses `git show --format=""` to /// get just the diff without commit metadata. -pub fn get_commit_diff(ref_str: &str) -> Result { +pub fn get_commit_diff(ref_str: &str) -> std::result::Result { validate_ref(ref_str)?; if ref_str.contains("..") { git_cmd(&["diff", ref_str]) @@ -94,22 +107,31 @@ pub fn get_commit_diff(ref_str: &str) -> Result { } /// Get the current branch name. -pub fn get_current_branch() -> Result { +pub fn get_current_branch() -> std::result::Result { let output = Command::new("git") .args(["rev-parse", "--abbrev-ref", "HEAD"]) .output() - .context("failed to execute git rev-parse")?; + .map_err(|e| CoraError::GitCommand { + command: "git rev-parse --abbrev-ref HEAD".into(), + stderr: e.to_string(), + })?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); - anyhow::bail!("git rev-parse failed: {stderr}"); + return Err(CoraError::GitCommand { + command: "git rev-parse --abbrev-ref HEAD".into(), + stderr: stderr.to_string(), + }); } - let name = String::from_utf8(output.stdout).context("branch name is not valid UTF-8")?; + let name = String::from_utf8(output.stdout).map_err(|e| CoraError::GitCommand { + command: "git rev-parse".into(), + stderr: e.to_string(), + })?; let name = name.trim().to_string(); if name.is_empty() || name == "HEAD" { - anyhow::bail!("HEAD is detached -- cannot determine branch name"); + return Err(CoraError::HeadDetached); } Ok(name) @@ -117,7 +139,7 @@ pub fn get_current_branch() -> Result { /// Try to get repository info: (owner, `repo_name`, branch). /// Owner is derived from the remote URL if possible. -pub fn get_repo_info() -> Result<(String, String, String)> { +pub fn get_repo_info() -> std::result::Result<(String, String, String), CoraError> { let repo = open_repo()?; let branch = get_current_branch()?; diff --git a/src/main.rs b/src/main.rs index 7f46fba2..b67f74c8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -7,6 +7,7 @@ use tracing_subscriber::FmtSubscriber; mod commands; mod config; mod engine; +mod error; mod formatters; mod git; mod hook;