diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c0e75e..8f97b31 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## [Unreleased] + + +### Features + +* CLI flags mirroring config settings — `--no-pr`, `--view`, `--no-flash`, `--flash-duration`, `--scroll-padding`, and `--edit-command`. Each overrides the config file (precedence: flag > config > default). + + +### Removed + +* Dead config options `[keys]`, `display.context_lines`, `pr.show_labels`, and the deprecated `pr.layout`. Existing config files using these keys still load — the keys are silently ignored. + ## [1.6.0](https://github.com/upsertco/perch/compare/v1.5.1...v1.6.0) (2026-05-29) diff --git a/CLAUDE.md b/CLAUDE.md index 3fab8f4..6902c16 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -21,7 +21,7 @@ src/ ├── ui/ # Rendering via ratatui │ └── mod.rs # Layout, file list, status line, help overlay └── config/ # Configuration loading and defaults - └── mod.rs # TOML parsing, XDG paths, default keybindings + └── mod.rs # TOML parsing, XDG paths, CLI-flag overrides ``` The repo is a Cargo workspace. The only non-root member is `xtask/` — dev tooling (`publish = false`, `dist = false`, never shipped in releases) that provides the `cargo dev-install` / `cargo run -p xtask -- uninstall` commands. @@ -134,14 +134,22 @@ Arguments: [PATH] Path to git repository or worktree (defaults to current directory) Options: - -c, --config Path to config file - -d, --debounce Debounce interval in milliseconds [default: 500] - --log Enable logging (trace, debug, info, warn, error) - --base Base branch for the branch-scoped diff range - -h, --help Print help - -V, --version Print version + -c, --config Path to config file + -d, --debounce Debounce interval in milliseconds [default: 500] + --log Enable logging (trace, debug, info, warn, error) + --base Base branch for the branch-scoped diff range + --no-pr Disable the GitHub PR status strip + --view Startup view (normal, condensed, tree) + --no-flash Disable the row flash on change + --flash-duration Flash duration in milliseconds + --scroll-padding Rows kept visible above/below the selection + --edit-command Editor command for the `e` key + -h, --help Print help + -V, --version Print version ``` +Flags that mirror config keys override the config file (precedence: flag > config > default). + ## Development Commands ```bash @@ -156,7 +164,7 @@ cargo dev-install # symlink the debug build into ~/.local/bin (xtas ## Current Status -Core feature set is complete: live file-list with numstat, status-grouped Normal view (plus Condensed and Tree view modes), PR status strip, in-app diff overlay, filesystem watching, config file + keybindings, multi-worktree support, and branch-scoped diff range. Colors follow the terminal's ANSI palette (no theming). +Core feature set is complete: live file-list with numstat, status-grouped Normal view (plus Condensed and Tree view modes), PR status strip, in-app diff overlay, filesystem watching, config file (with mirroring CLI flags), multi-worktree support, and branch-scoped diff range. Colors follow the terminal's ANSI palette (no theming). Remaining open items: diff --git a/README.md b/README.md index 65e254b..ef46c30 100644 --- a/README.md +++ b/README.md @@ -45,9 +45,17 @@ perch can be launched from any directory inside a git working tree — the repos | ------------------------- | ----------------------------------------------------------------------- | | `[PATH]` | Repository path (default `.`) | | `-c, --config ` | Path to config file | -| `-d, --debounce ` | Filesystem debounce in ms (default `200`) | -| `--log ` | Logging level: `trace`, `debug`, `info`, `warn`, `error` | +| `-d, --debounce ` | Filesystem debounce in ms (default `500`) | | `--base ` | Base branch for the branch-scoped diff range | +| `--no-pr` | Disable the GitHub PR status strip | +| `--view ` | Startup view: `normal`, `condensed`, `tree` | +| `--no-flash` | Disable the row flash on change | +| `--flash-duration ` | Flash duration in ms | +| `--scroll-padding ` | Rows kept visible above/below the selection | +| `--edit-command ` | Editor command for the `e` key | +| `--log ` | Logging level: `trace`, `debug`, `info`, `warn`, `error` | + +Any flag that mirrors a config setting overrides the config file. Precedence is **CLI flag > config file > built-in default**. ## Keybindings @@ -79,7 +87,7 @@ Config lives at `~/.config/perch/config.toml`. All sections are optional; defaul ### Top-level ```toml -debounce_ms = 200 # filesystem event debounce in ms +debounce_ms = 500 # filesystem event debounce in ms base_branch = "main" # optional override for branch-scoped diff base ``` @@ -94,23 +102,10 @@ working-tree status and Normal hides the Committed group. ```toml [display] -context_lines = 3 # diff context lines +default_view = "normal" # startup view: normal | condensed | tree flash_on_change = true # flash a file row when it changes flash_duration_ms = 600 # flash duration -``` - -### `[keys]` - -Rebindable single-character keys. - -```toml -[keys] -quit = "q" -up = "k" -down = "j" -expand = "l" -collapse = "h" -refresh = "r" +scroll_padding = 3 # rows kept visible around the selection (0 disables) ``` ### `[pr]` @@ -120,7 +115,6 @@ Controls the compact PR status strip that appears when the current branch has an ```toml [pr] enabled = true -show_labels = false ``` The GitHub token is discovered from the `GITHUB_TOKEN` environment variable or your `git config`. If no token is available the PR strip silently stays hidden. @@ -128,17 +122,17 @@ The GitHub token is discovered from the `GITHUB_TOKEN` environment variable or y ### Full example ```toml -debounce_ms = 200 +debounce_ms = 500 base_branch = "main" [display] -context_lines = 3 +default_view = "normal" flash_on_change = true flash_duration_ms = 600 +scroll_padding = 3 [pr] enabled = true -show_labels = false ``` ## Colors diff --git a/src/app.rs b/src/app.rs index 125a5cb..617e3c2 100644 --- a/src/app.rs +++ b/src/app.rs @@ -106,8 +106,6 @@ pub struct App { /// branch renames so the PR poller can be restarted and a flash message /// shown. last_seen_branch: Option, - /// CLI override for base branch - base_override: Option, /// Sender for worker requests. Bounded; drops on overflow are safe /// since FS-driven recomputes are idempotent. worker_tx: Sender, @@ -272,13 +270,7 @@ impl Drop for TerminalGuard<'_> { } impl App { - pub fn new( - watch_path: PathBuf, - repo_path: PathBuf, - config: AppConfig, - debounce_ms: u64, - base_override: Option, - ) -> Result { + pub fn new(watch_path: PathBuf, repo_path: PathBuf, config: AppConfig) -> Result { // Open the repo just long enough to read the branch name (sub-millisecond // — reads `.git/HEAD`). Everything else — file list, head_info, ahead/behind, // stash count, repo_state, merge_base — is deferred to the worker so @@ -303,7 +295,7 @@ impl App { // the first draw) returns immediately. Live FS updates are disabled // until the watcher is installed; a catch-up Recompute fires then. let t = Instant::now(); - let debounce = Duration::from_millis(debounce_ms); + let debounce = Duration::from_millis(config.debounce_ms); let watcher_pending_rx = spawn_watcher_init(watch_path.clone(), debounce); tracing::debug!( elapsed_ms = t.elapsed().as_millis() as u64, @@ -340,7 +332,6 @@ impl App { let (worker_resp_tx, worker_rx) = bounded::(8); let worker_handle = Worker::spawn( watch_path.clone(), - base_override.clone(), config.base_branch.clone(), worker_req_rx, worker_resp_tx, @@ -370,7 +361,6 @@ impl App { main_missing_warned: false, gh_rx, last_seen_branch: None, - base_override, worker_tx, worker_rx, worker_handle: Some(worker_handle), @@ -1242,7 +1232,6 @@ mod input_tests { main_missing_warned: false, gh_rx: None, last_seen_branch: None, - base_override: None, worker_tx, worker_rx, worker_handle: None, diff --git a/src/config/mod.rs b/src/config/mod.rs index 50baa90..c71d1a3 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -15,13 +15,11 @@ pub struct AppConfig { pub display: DisplayConfig, /// PR widget configuration pub pr: PrConfig, - /// Keybinding overrides - pub keys: KeyConfig, /// Shell command used to open a file for editing. Falls back to `$EDITOR`, /// then to `vim` when unset. pub edit_command: Option, /// Base branch for branch-scoped diff (e.g. "main", "develop"). - /// Auto-detected from remote if omitted. + /// Auto-detected if omitted (see base resolution tiers). pub base_branch: Option, } @@ -31,7 +29,6 @@ impl Default for AppConfig { debounce_ms: 500, display: DisplayConfig::default(), pr: PrConfig::default(), - keys: KeyConfig::default(), edit_command: None, base_branch: None, } @@ -42,8 +39,6 @@ impl Default for AppConfig { #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(default)] pub struct DisplayConfig { - /// Number of diff context lines to show around changes - pub context_lines: usize, /// Flash the background of a file row when its diff stats change pub flash_on_change: bool, /// Duration in milliseconds for the flash effect @@ -59,7 +54,6 @@ pub struct DisplayConfig { impl Default for DisplayConfig { fn default() -> Self { Self { - context_lines: 3, flash_on_change: true, flash_duration_ms: 600, scroll_padding: 3, @@ -74,46 +68,11 @@ impl Default for DisplayConfig { pub struct PrConfig { /// Whether the PR tab / polling is enabled pub enabled: bool, - /// Whether to show PR labels - pub show_labels: bool, - /// **Deprecated**: retained so old config files parse cleanly. No longer - /// affects rendering since the PR widget is now a tab. - #[serde(default)] - pub layout: Option, } impl Default for PrConfig { fn default() -> Self { - Self { - enabled: true, - show_labels: false, - layout: None, - } - } -} - -/// Keybinding configuration -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(default)] -pub struct KeyConfig { - pub quit: String, - pub up: String, - pub down: String, - pub expand: String, - pub collapse: String, - pub refresh: String, -} - -impl Default for KeyConfig { - fn default() -> Self { - Self { - quit: "q".to_string(), - up: "k".to_string(), - down: "j".to_string(), - expand: "l".to_string(), - collapse: "h".to_string(), - refresh: "r".to_string(), - } + Self { enabled: true } } } @@ -139,12 +98,6 @@ impl AppConfig { .with_context(|| format!("Failed to parse config file: {}", p.display()))?; tracing::info!(?p, "Loaded config"); - if config.pr.layout.is_some() { - tracing::warn!( - "`pr.layout` is deprecated and ignored — the PR widget is now a tab." - ); - } - Ok(config) } _ => { @@ -159,11 +112,45 @@ impl AppConfig { mod tests { use super::*; + #[test] + fn test_removed_keys_are_ignored_gracefully() { + let dir = std::env::temp_dir().join("perch-test-config-removed-keys"); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("config.toml"); + std::fs::write( + &path, + r#" +debounce_ms = 250 + +[display] +context_lines = 9 +flash_on_change = false + +[pr] +enabled = false +show_labels = true +layout = "right" + +[keys] +quit = "x" +up = "w" +"#, + ) + .unwrap(); + + // Removed keys are unknown to serde and silently ignored; known keys still apply. + let config = AppConfig::load(Some(&path)).unwrap(); + assert_eq!(config.debounce_ms, 250); + assert!(!config.display.flash_on_change); + assert!(!config.pr.enabled); + + std::fs::remove_dir_all(&dir).ok(); + } + #[test] fn test_default_config() { let config = AppConfig::default(); assert_eq!(config.debounce_ms, 500); - assert_eq!(config.display.context_lines, 3); assert!(config.display.flash_on_change); assert_eq!(config.display.flash_duration_ms, 600); assert_eq!(config.display.scroll_padding, 3); @@ -173,19 +160,6 @@ mod tests { fn test_default_pr_config() { let pr = PrConfig::default(); assert!(pr.enabled); - assert!(pr.layout.is_none()); - assert!(!pr.show_labels); - } - - #[test] - fn test_default_keys() { - let keys = KeyConfig::default(); - assert_eq!(keys.quit, "q"); - assert_eq!(keys.up, "k"); - assert_eq!(keys.down, "j"); - assert_eq!(keys.expand, "l"); - assert_eq!(keys.collapse, "h"); - assert_eq!(keys.refresh, "r"); } #[test] @@ -206,14 +180,12 @@ mod tests { r#" [pr] enabled = false -layout = "right" "#, ) .unwrap(); let config = AppConfig::load(Some(&path)).unwrap(); assert!(!config.pr.enabled); - assert_eq!(config.pr.layout.as_deref(), Some("right")); std::fs::remove_dir_all(&dir).ok(); } @@ -229,10 +201,8 @@ layout = "right" assert_eq!(config.edit_command.as_deref(), Some("nvim")); // Unspecified fields use defaults assert_eq!(config.debounce_ms, 500); - assert_eq!(config.display.context_lines, 3); assert!(config.display.flash_on_change); assert!(config.pr.enabled); - assert!(config.pr.layout.is_none()); assert_eq!(config.display.scroll_padding, 3); std::fs::remove_dir_all(&dir).ok(); @@ -324,6 +294,43 @@ layout = "right" std::fs::remove_dir_all(&dir).ok(); } + /// `ViewMode` derives both serde (`rename_all = "lowercase"`, used here for + /// `display.default_view`) and `clap::ValueEnum` (used for the `--view` + /// flag). The two derives generate their string mappings independently, so + /// this guards that a given spelling resolves to the same variant through + /// both paths — a rename or `#[serde(rename = ...)]` on one derive only + /// would break this and silently diverge CLI from config. + #[test] + fn view_mode_cli_and_config_spellings_agree() { + use crate::state::ViewMode; + use clap::ValueEnum; + + let dir = std::env::temp_dir().join("perch-test-config-view-parity"); + std::fs::create_dir_all(&dir).unwrap(); + + for variant in ViewMode::value_variants() { + // The spelling clap accepts on the CLI for this variant. + let cli_name = variant + .to_possible_value() + .expect("ViewMode variants are not skipped"); + let name = cli_name.get_name(); + + // The same spelling must parse through config TOML to the same variant. + let path = dir.join("config.toml"); + std::fs::write(&path, format!("[display]\ndefault_view = \"{name}\"\n")).unwrap(); + let config = AppConfig::load(Some(&path)).unwrap(); + + assert_eq!( + config.display.default_view, *variant, + "config spelling \"{name}\" must resolve to the same variant clap uses" + ); + // And clap must round-trip its own spelling back to this variant. + assert_eq!(ViewMode::from_str(name, true).unwrap(), *variant); + } + + std::fs::remove_dir_all(&dir).ok(); + } + #[test] fn test_base_branch_config() { let dir = std::env::temp_dir().join("perch-test-config-base-branch"); @@ -343,33 +350,6 @@ layout = "right" assert!(config.base_branch.is_none()); } - #[test] - fn test_legacy_pr_layout_parses_without_error() { - let dir = std::env::temp_dir().join("perch-test-config-legacy-layout"); - std::fs::create_dir_all(&dir).unwrap(); - let path = dir.join("config.toml"); - std::fs::write( - &path, - r#" -[pr] -enabled = true -layout = "right" -show_labels = false -"#, - ) - .unwrap(); - - // Legacy `layout` key should parse cleanly — accepted but deprecated. - let config = AppConfig::load(Some(&path)).unwrap(); - assert!(config.pr.enabled); - assert!(!config.pr.show_labels); - // The deprecated field is still present in the struct but its value is - // irrelevant to runtime behavior. - assert_eq!(config.pr.layout.as_deref(), Some("right")); - - std::fs::remove_dir_all(&dir).ok(); - } - #[test] fn edit_command_round_trip() { let toml = r#"edit_command = "nvim -p""#; diff --git a/src/git/worker.rs b/src/git/worker.rs index c15d13f..f863e4a 100644 --- a/src/git/worker.rs +++ b/src/git/worker.rs @@ -137,15 +137,14 @@ impl Worker { /// dropped. pub fn spawn( repo_path: PathBuf, - base_override: Option, - config_base: Option, + base: Option, req_rx: Receiver, resp_tx: Sender, ) -> JoinHandle<()> { thread::Builder::new() .name("git-worker".to_string()) .spawn(move || { - Self::run(repo_path, base_override, config_base, req_rx, resp_tx); + Self::run(repo_path, base, req_rx, resp_tx); }) .expect("failed to spawn git-worker thread") } @@ -153,8 +152,7 @@ impl Worker { #[tracing::instrument(name = "git.worker.run", skip_all)] fn run( repo_path: PathBuf, - base_override: Option, - config_base: Option, + base: Option, req_rx: Receiver, resp_tx: Sender, ) { @@ -188,17 +186,11 @@ impl Worker { for req in queue { match req { Request::Recompute => { - let bundle = - compute_status(&git, base_override.as_deref(), config_base.as_deref()); + let bundle = compute_status(&git, base.as_deref()); let _ = resp_tx.send(Response::Status(Box::new(bundle))); } Request::Diff { path, token } => { - match compute_diff( - &git, - &path, - base_override.as_deref(), - config_base.as_deref(), - ) { + match compute_diff(&git, &path, base.as_deref()) { Ok(diff) => { let _ = resp_tx.send(Response::Diff { path, token, diff }); } @@ -227,13 +219,9 @@ impl Worker { /// Compute the status bundle for the current worktree. Resolves the diff /// base via strict default-branch resolution and delegates to `compute_with_base`. /// Errors degrade to default fields rather than failing the whole bundle. -fn compute_status( - git: &GitRepo, - base_override: Option<&str>, - config_base: Option<&str>, -) -> StatusBundle { +fn compute_status(git: &GitRepo, base: Option<&str>) -> StatusBundle { let current_branch = git.branch_name().unwrap_or_else(|_| "HEAD".to_string()); - let resolved_base = git.resolve_base_branch(base_override.or(config_base)); + let resolved_base = git.resolve_base_branch(base); compute_with_base(git, resolved_base, current_branch) } @@ -272,10 +260,9 @@ fn compute_with_base( fn compute_diff( git: &GitRepo, path: &str, - base_override: Option<&str>, - config_base: Option<&str>, + base: Option<&str>, ) -> Result { - let resolved_base = git.resolve_base_branch(base_override.or(config_base)); + let resolved_base = git.resolve_base_branch(base); if let Some(base_name) = resolved_base { if let Ok(Some(mb)) = git.merge_base(&base_name) { return git.branch_diff_file(path, mb); @@ -405,7 +392,7 @@ mod tests { let (req_tx, req_rx) = bounded::(8); let (resp_tx, resp_rx) = bounded::(8); - let handle = Worker::spawn(repo_path.clone(), None, None, req_rx, resp_tx); + let handle = Worker::spawn(repo_path.clone(), None, req_rx, resp_tx); req_tx.send(Request::Recompute).unwrap(); let resp = resp_rx @@ -458,7 +445,7 @@ mod tests { let (req_tx, req_rx) = bounded::(8); let (resp_tx, resp_rx) = bounded::(8); - let handle = Worker::spawn(tmp_a.path().to_path_buf(), None, None, req_rx, resp_tx); + let handle = Worker::spawn(tmp_a.path().to_path_buf(), None, req_rx, resp_tx); // First recompute for repo A. req_tx.send(Request::Recompute).unwrap(); @@ -560,7 +547,7 @@ mod tests { g(&["commit", "-q", "-m", "s1"]); // Stay on the sibling and run compute_status let git = crate::git::GitRepo::new(&work).unwrap(); - let bundle = compute_status(&git, None, None); + let bundle = compute_status(&git, None); assert_eq!( bundle.base_branch, "main", "expected strict default-branch base, got {:?}", @@ -605,7 +592,7 @@ mod tests { std::fs::write(work.join("untracked.txt"), "new\n").unwrap(); let git = crate::git::GitRepo::new(&work).unwrap(); - let bundle = compute_status(&git, None, None); + let bundle = compute_status(&git, None); assert_eq!(bundle.base_branch, ""); assert!(bundle.merge_base.is_none()); diff --git a/src/main.rs b/src/main.rs index 66c9ff1..2e9774c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -28,11 +28,11 @@ const VERSION: &str = if cfg!(debug_assertions) { env!("CARGO_PKG_VERSION") }; -#[derive(Parser, Debug)] +#[derive(Parser, Debug, Default)] #[command( name = "perch", version = VERSION, - about = "Real-time terminal dashboard for git changes" + about = "Tracking Real-Time Git Changes During Agentic Workflows" )] struct Cli { /// Path inside a git repository (defaults to current directory; repo root is discovered) @@ -43,18 +43,72 @@ struct Cli { #[arg(short, long)] config: Option, - /// Debounce interval in milliseconds - #[arg(short, long, default_value_t = 500)] - debounce: u64, + /// Debounce interval in milliseconds (overrides config) + #[arg(short, long)] + debounce: Option, /// Enable logging at the given level (trace, debug, info, warn, error) #[arg(long)] log: Option, /// Base branch for branch-scoped diff (overrides config). - /// Auto-detected from remote if omitted. + /// Auto-detected if omitted. #[arg(long)] base: Option, + + /// Disable the GitHub PR status strip (overrides config) + #[arg(long)] + no_pr: bool, + + /// Startup view mode (overrides config) + #[arg(long, value_enum)] + view: Option, + + /// Disable the row flash on change (overrides config) + #[arg(long)] + no_flash: bool, + + /// Flash duration in milliseconds (overrides config) + #[arg(long)] + flash_duration: Option, + + /// Rows of context kept above/below the selection (overrides config) + #[arg(long)] + scroll_padding: Option, + + /// Shell command used to open a file for editing (overrides config) + #[arg(long)] + edit_command: Option, +} + +/// Overlay CLI flags onto a loaded config. Precedence: CLI flag > config > default. +/// Value flags override only when present; `--no-pr`/`--no-flash` only force off. +fn merge_cli_overrides(mut config: config::AppConfig, cli: &Cli) -> config::AppConfig { + if let Some(d) = cli.debounce { + config.debounce_ms = d; + } + if let Some(b) = cli.base.clone() { + config.base_branch = Some(b); + } + if cli.no_pr { + config.pr.enabled = false; + } + if let Some(v) = cli.view { + config.display.default_view = v; + } + if cli.no_flash { + config.display.flash_on_change = false; + } + if let Some(ms) = cli.flash_duration { + config.display.flash_duration_ms = ms; + } + if let Some(n) = cli.scroll_padding { + config.display.scroll_padding = n; + } + if let Some(cmd) = cli.edit_command.clone() { + config.edit_command = Some(cmd); + } + config } fn main() -> Result<()> { @@ -96,6 +150,7 @@ fn main() -> Result<()> { let t = std::time::Instant::now(); let config = config::AppConfig::load(cli.config.as_deref())?; + let config = merge_cli_overrides(config, &cli); tracing::debug!( elapsed_ms = t.elapsed().as_millis() as u64, "startup: config load" @@ -104,7 +159,7 @@ fn main() -> Result<()> { let watch_path = repo_path.clone(); let t = std::time::Instant::now(); - let mut app = app::App::new(watch_path, repo_path, config, cli.debounce, cli.base)?; + let mut app = app::App::new(watch_path, repo_path, config)?; tracing::debug!( elapsed_ms = t.elapsed().as_millis() as u64, "startup: App::new" @@ -131,3 +186,69 @@ mod tests { assert!(VERSION.contains("-dev."), "got {VERSION}"); } } + +#[cfg(test)] +mod merge_tests { + use super::*; + use crate::config::{AppConfig, DisplayConfig}; + use crate::state::ViewMode; + use std::path::PathBuf; + + fn bare_cli() -> Cli { + Cli { + path: PathBuf::from("."), + ..Default::default() + } + } + + #[test] + fn absent_flags_keep_config_values() { + let config = AppConfig { + debounce_ms: 250, + display: DisplayConfig { + default_view: ViewMode::Tree, + ..Default::default() + }, + ..Default::default() + }; + let merged = merge_cli_overrides(config, &bare_cli()); + assert_eq!(merged.debounce_ms, 250); + assert_eq!(merged.display.default_view, ViewMode::Tree); + assert!(merged.pr.enabled); + assert!(merged.display.flash_on_change); + } + + #[test] + fn value_flags_override_config() { + let config = AppConfig::default(); + let cli = Cli { + debounce: Some(900), + base: Some("develop".to_string()), + view: Some(ViewMode::Condensed), + flash_duration: Some(123), + scroll_padding: Some(7), + edit_command: Some("nano".to_string()), + ..bare_cli() + }; + let merged = merge_cli_overrides(config, &cli); + assert_eq!(merged.debounce_ms, 900); + assert_eq!(merged.base_branch.as_deref(), Some("develop")); + assert_eq!(merged.display.default_view, ViewMode::Condensed); + assert_eq!(merged.display.flash_duration_ms, 123); + assert_eq!(merged.display.scroll_padding, 7); + assert_eq!(merged.edit_command.as_deref(), Some("nano")); + } + + #[test] + fn no_pr_and_no_flash_force_off() { + let config = AppConfig::default(); // both default true + let cli = Cli { + no_pr: true, + no_flash: true, + ..bare_cli() + }; + let merged = merge_cli_overrides(config, &cli); + assert!(!merged.pr.enabled); + assert!(!merged.display.flash_on_change); + } +} diff --git a/src/state/mod.rs b/src/state/mod.rs index 08bc836..8d3ed07 100644 --- a/src/state/mod.rs +++ b/src/state/mod.rs @@ -84,7 +84,9 @@ pub enum MergeableStatus { Unknown, } -#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[derive( + Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize, clap::ValueEnum, +)] #[serde(rename_all = "lowercase")] pub enum ViewMode { /// Single flat list of every changed file path. @@ -1915,4 +1917,19 @@ mod tests { state.scroll_diff_to_bottom(); assert_eq!(state.diff_scroll(), 0); } + + #[test] + fn view_mode_parses_from_cli_value() { + use clap::ValueEnum; + assert_eq!( + ViewMode::from_str("normal", true).unwrap(), + ViewMode::Normal + ); + assert_eq!(ViewMode::from_str("tree", true).unwrap(), ViewMode::Tree); + assert_eq!( + ViewMode::from_str("condensed", true).unwrap(), + ViewMode::Condensed + ); + assert!(ViewMode::from_str("expanded", true).is_err()); + } }