Skip to main content

omni_dev/
cli.rs

1//! CLI interface for omni-dev.
2
3use anyhow::Result;
4use clap::{Parser, Subcommand};
5
6pub mod ai;
7pub mod ai_backend_args;
8pub mod atlassian;
9pub mod browser;
10pub mod commands;
11pub mod completions;
12pub mod config;
13pub(crate) mod confirm;
14pub mod coverage;
15// The daemon and the Snowflake client (which talks to the daemon over its
16// Unix-domain control socket) are Unix-only; on Windows they run only under WSL2,
17// and a native (non-WSL) Windows port is future work (#1363).
18#[cfg(unix)]
19pub mod claude_wrap;
20#[cfg(unix)]
21pub mod codex_wrap;
22#[cfg(unix)]
23pub mod daemon;
24pub mod datadog;
25pub mod drive;
26pub mod format;
27pub mod git;
28pub mod gmail;
29pub mod help;
30pub mod log;
31pub mod repo_arg;
32pub mod resources;
33#[cfg(unix)]
34pub mod sessions;
35#[cfg(unix)]
36pub mod snowflake;
37pub mod transcript;
38#[cfg(unix)]
39pub mod worktrees;
40
41// The `--ai-backend` value enum lives with the shared backend/model resolver;
42// re-exported here so `crate::cli::AiBackend` keeps working.
43pub use crate::claude::backend::AiBackend;
44
45/// Top-level clap-derived CLI struct; the library entry point for embedding
46/// omni-dev programmatically.
47///
48/// The only global flag is `--profile`, the one flag every credentialed
49/// command reads; it is propagated to `OMNI_DEV_PROFILE` before dispatching to
50/// a [`Commands`] variant. Every other flag is declared only on the commands
51/// that read it — see the placement rule in [`ai_backend_args`] (#1778).
52#[derive(Parser)]
53#[command(name = "omni-dev")]
54#[command(
55    about = "AI-powered git commit rewriter, PR generator, and MCP server for Jira, Confluence, and Datadog.",
56    long_about = None
57)]
58// `-V` shows the bare crate version; `--version` adds git provenance (commit,
59// date, dirty flag) so a local/unreleased build is identifiable (#1374).
60#[command(version = crate::VERSION, long_version = crate::build_info::long_version())]
61pub struct Cli {
62    /// Selects a named credential/config profile from
63    /// `~/.omni-dev/settings.json` (AWS-CLI style).
64    ///
65    /// When set, the profile's `env` bundle replaces the base `env` map in the
66    /// settings-fallback chain (process env still wins); the base map is not
67    /// consulted. Overrides `OMNI_DEV_PROFILE`. An unknown name is a hard error
68    /// listing the known profiles.
69    #[arg(long, global = true, value_name = "NAME")]
70    pub profile: Option<String>,
71
72    /// The main command to execute.
73    #[command(subcommand)]
74    pub command: Commands,
75}
76
77/// Top-level subcommand dispatch enum.
78///
79/// Each variant wraps the subcommand-specific argument struct (e.g.
80/// [`ai::AiCommand`], [`git::GitCommand`], [`atlassian::AtlassianCommand`]);
81/// follow the variant's payload type for the per-command argument surface.
82#[derive(Subcommand)]
83pub enum Commands {
84    /// AI operations.
85    Ai(ai::AiCommand),
86    /// Git-related operations.
87    Git(git::GitCommand),
88    /// Command template management.
89    Commands(commands::CommandsCommand),
90    /// Configuration and model information.
91    Config(config::ConfigCommand),
92    /// Atlassian: JIRA and Confluence operations.
93    Atlassian(atlassian::AtlassianCommand),
94    /// Browser bridge: drive authenticated requests through a browser tab.
95    Browser(browser::BrowserCommand),
96    /// Daemon: host long-lived services (e.g. the browser bridge).
97    #[cfg(unix)]
98    Daemon(daemon::DaemonCommand),
99    /// Datadog: read-only API operations.
100    Datadog(datadog::DatadogCommand),
101    /// Drive: search and read Google Drive files via OAuth2 (read-only).
102    Drive(drive::DriveCommand),
103    /// Gmail: search, read, and label messages via OAuth2.
104    Gmail(gmail::GmailCommand),
105    /// Snowflake: run arbitrary SQL through the daemon's multiplexed sessions.
106    #[cfg(unix)]
107    Snowflake(snowflake::SnowflakeCommand),
108    /// Worktrees: list the repos/worktrees open across all VS Code windows.
109    #[cfg(unix)]
110    Worktrees(worktrees::WorktreesCommand),
111    /// Sessions: track Claude Code sessions running across all terminals and windows.
112    #[cfg(unix)]
113    Sessions(sessions::SessionsCommand),
114    /// Wrap the Claude process, reporting its exact session state to the daemon.
115    #[cfg(unix)]
116    #[command(name = "claude-wrap")]
117    ClaudeWrap(claude_wrap::ClaudeWrapCommand),
118    /// Run the Codex TUI against a private app-server, reporting its exact
119    /// session state to the daemon.
120    #[cfg(unix)]
121    #[command(name = "codex-wrap")]
122    CodexWrap(codex_wrap::CodexWrapCommand),
123    /// Coverage: diff/patch coverage analysis for PR comments.
124    Coverage(coverage::CoverageCommand),
125    /// Transcript and caption fetching from media platforms.
126    Transcript(transcript::TranscriptCommand),
127    /// Search the local invocation + HTTP request log.
128    Log(log::LogCommand),
129    /// Embedded reference resources (specs, etc.).
130    Resources(resources::ResourcesCommand),
131    /// Generates shell completion scripts.
132    #[command(hide = true)]
133    Completions(completions::CompletionsCommand),
134    /// Displays comprehensive help for all commands.
135    #[command(name = "help-all")]
136    HelpAll(help::HelpCommand),
137}
138
139impl Cli {
140    /// Forwards `--profile` to the env var the settings readers discover the
141    /// active profile from. Extracted so it can be unit-tested without
142    /// invoking a real subcommand.
143    fn propagate_profile_flag(&self) {
144        // The flag beats the env var: setting OMNI_DEV_PROFILE here means the
145        // settings readers (which discover the active profile from that env
146        // var) pick up the flag. When the flag is absent we leave any existing
147        // OMNI_DEV_PROFILE untouched, so the env-var path still works.
148        if let Some(profile) = &self.profile {
149            std::env::set_var(crate::utils::settings::PROFILE_ENV_VAR, profile);
150        }
151    }
152
153    /// Validates the active profile (resolved from `env`) against the settings
154    /// produced by `load_settings`. The loader is invoked only when a profile is
155    /// actually active, so a no-profile invocation reads no disk. Pure over its
156    /// inputs — unit-tested with a `MapEnv` and a constructed `Settings` rather
157    /// than the process environment and `~/.omni-dev/settings.json`.
158    fn validate_active_profile<E, F>(env: &E, load_settings: F) -> Result<()>
159    where
160        E: crate::utils::env::EnvSource,
161        F: FnOnce() -> crate::utils::settings::Settings,
162    {
163        match crate::utils::settings::active_profile_from(env) {
164            Some(name) => load_settings().validate_profile(&name),
165            None => Ok(()),
166        }
167    }
168
169    /// Thin disk boundary for [`Self::validate_active_profile`]: loads
170    /// `~/.omni-dev/settings.json`, degrading to defaults when it is absent
171    /// (silently) or unreadable/unparseable (with a warning) rather than
172    /// failing (an unreadable settings file must not block commands that use
173    /// no profile). A named function so it can be unit-tested directly instead
174    /// of as an inline closure.
175    fn load_settings_or_default() -> crate::utils::settings::Settings {
176        crate::utils::settings::Settings::load_or_warn_default()
177    }
178
179    /// Executes the CLI command.
180    pub async fn execute(self) -> Result<()> {
181        self.propagate_profile_flag();
182
183        // Validate the selected profile once, before dispatch, so a typo fails
184        // fast rather than silently falling back to base credentials. The loader
185        // runs only when a profile is active, so a no-profile invocation pays no
186        // extra disk I/O.
187        Self::validate_active_profile(
188            &crate::utils::env::SystemEnv,
189            Self::load_settings_or_default,
190        )?;
191
192        match self.command {
193            Commands::Ai(ai_cmd) => ai_cmd.execute().await,
194            Commands::Git(git_cmd) => git_cmd.execute().await,
195            Commands::Commands(commands_cmd) => commands_cmd.execute(),
196            Commands::Atlassian(cmd) => cmd.execute().await,
197            Commands::Browser(cmd) => cmd.execute().await,
198            #[cfg(unix)]
199            Commands::Daemon(cmd) => cmd.execute().await,
200            Commands::Datadog(cmd) => cmd.execute().await,
201            Commands::Drive(cmd) => cmd.execute().await,
202            Commands::Gmail(cmd) => cmd.execute().await,
203            #[cfg(unix)]
204            Commands::Snowflake(cmd) => cmd.execute().await,
205            #[cfg(unix)]
206            Commands::Worktrees(cmd) => cmd.execute().await,
207            #[cfg(unix)]
208            Commands::Sessions(cmd) => cmd.execute().await,
209            #[cfg(unix)]
210            Commands::ClaudeWrap(cmd) => cmd.execute().await,
211            #[cfg(unix)]
212            Commands::CodexWrap(cmd) => cmd.execute().await,
213            Commands::Coverage(cmd) => cmd.execute(),
214            Commands::Transcript(cmd) => cmd.execute().await,
215            Commands::Log(log_cmd) => log_cmd.execute(),
216            Commands::Config(config_cmd) => config_cmd.execute(),
217            Commands::Resources(resources_cmd) => resources_cmd.execute(),
218            Commands::Completions(completions_cmd) => completions_cmd.execute(),
219            Commands::HelpAll(help_cmd) => help_cmd.execute(),
220        }
221    }
222}
223
224#[cfg(all(target_os = "macos", feature = "menu-bar"))]
225impl Cli {
226    /// If this invocation is `daemon run` without `--no-menu`, resolves the
227    /// daemon configuration so `main` can host it with a macOS menu-bar tray on
228    /// the main thread. Returns `None` for every other invocation (which runs
229    /// normally on the async runtime).
230    pub fn menu_bar_run_config(&self) -> Option<Result<crate::daemon::DaemonRunConfig>> {
231        match &self.command {
232            Commands::Daemon(daemon::DaemonCommand {
233                command: daemon::DaemonSubcommands::Run(run),
234            }) if !run.no_menu => Some(run.clone().into_run_config()),
235            _ => None,
236        }
237    }
238}
239
240#[cfg(test)]
241#[allow(clippy::unwrap_used, clippy::expect_used)]
242mod tests {
243    use super::*;
244
245    // `execute()`'s command dispatch is otherwise only exercised by spawning
246    // the real binary in integration tests; this covers the `Gmail` arm
247    // in-process (deterministic, network-free: missing credentials fail
248    // fast before any Gmail API call).
249    #[tokio::test]
250    async fn execute_routes_gmail_subcommand() {
251        let guard = crate::gmail::test_support::EnvGuard::take();
252        let _dir = guard.clear_credentials();
253
254        let cli = Cli::try_parse_from(["omni-dev", "gmail", "auth", "status"]).unwrap();
255        let err = cli.execute().await.unwrap_err();
256        assert!(err.to_string().contains("not configured"));
257    }
258
259    // `execute()`'s command dispatch is otherwise only exercised by spawning
260    // the real binary in integration tests; this covers the `Drive` arm
261    // in-process (deterministic, network-free: missing credentials fail
262    // fast before any Drive API call).
263    #[tokio::test]
264    async fn execute_routes_drive_subcommand() {
265        let guard = crate::drive::test_support::EnvGuard::take();
266        let _dir = guard.clear_credentials();
267
268        let cli = Cli::try_parse_from(["omni-dev", "drive", "auth", "status"]).unwrap();
269        let err = cli.execute().await.unwrap_err();
270        assert!(err.to_string().contains("not configured"));
271    }
272
273    // ── global arg-id collision tests (#1420) ──
274
275    /// A `global = true` arg is propagated by clap **arg id**, and the derive's
276    /// id defaults to the field name — so a subcommand-local field named `repo`
277    /// displaced the then-root-global `-C/--repo` under `worktrees register`
278    /// and panicked (#1420); the local field became `repo_name`
279    /// (`--repo-name`). Since #1778 `-C/--repo` is scoped to the commands that
280    /// read it, which `worktrees register` is not, so it now rejects `-C`
281    /// outright while `--repo-name` keeps parsing.
282    #[cfg(unix)]
283    #[test]
284    fn worktrees_register_takes_repo_name_but_not_repo() {
285        let register = [
286            "worktrees",
287            "register",
288            "--key",
289            "k1",
290            "--repo-name",
291            "myrepo",
292            "--folder",
293            "/tmp",
294        ];
295        let cli = Cli::try_parse_from(std::iter::once("omni-dev").chain(register)).unwrap();
296        let Commands::Worktrees(worktrees::WorktreesCommand {
297            command: worktrees::WorktreesSubcommands::Register(register_cmd),
298        }) = cli.command
299        else {
300            panic!("expected a `worktrees register` invocation");
301        };
302        assert_eq!(register_cmd.key, "k1");
303        assert_eq!(register_cmd.repo_name.as_deref(), Some("myrepo"));
304
305        let with_repo = std::iter::once("omni-dev")
306            .chain(register)
307            .chain(["-C", "/tmp/somerepo"]);
308        assert!(Cli::try_parse_from(with_repo).is_err());
309    }
310
311    /// Pins which leaf commands accept each scoped flag (#1778). Every flag
312    /// except `--profile` is declared only on the commands that read it, so a
313    /// new command can only gain one on purpose — and a flag that silently
314    /// spreads to a command that ignores it fails here.
315    #[test]
316    fn scoped_flags_are_accepted_only_by_their_readers() {
317        use clap::CommandFactory;
318
319        fn leaves(cmd: &clap::Command, path: &str, out: &mut Vec<(String, Vec<String>)>) {
320            let subs: Vec<_> = cmd
321                .get_subcommands()
322                .filter(|s| s.get_name() != "help")
323                .collect();
324            if subs.is_empty() {
325                let longs = cmd
326                    .get_arguments()
327                    .filter_map(|a| a.get_long().map(str::to_string))
328                    .collect();
329                out.push((path.to_string(), longs));
330            }
331            for sub in subs {
332                leaves(sub, &format!("{path} {}", sub.get_name()), out);
333            }
334        }
335
336        // `build` propagates every (subtree-)global arg into its descendants,
337        // so each leaf's argument list is exactly what it accepts.
338        let mut cmd = Cli::command();
339        cmd.build();
340        let mut all = Vec::new();
341        leaves(&cmd, "omni-dev", &mut all);
342
343        let under = |path: &str, owners: &[&str]| {
344            owners.iter().any(|o| {
345                let o = format!("omni-dev {o}");
346                path == o || path.starts_with(&format!("{o} "))
347            })
348        };
349        const AI: &[&str] = &[
350            "git commit message twiddle",
351            "git commit message check",
352            "git commit message staged",
353            "git branch create pr",
354            "ai chat",
355            "ai jev verify-decision",
356        ];
357        let expected: [(&str, Vec<&str>); 4] = [
358            ("ai-backend", AI.to_vec()),
359            ("models-yaml", [AI, &["config models show"]].concat()),
360            (
361                "repo",
362                vec![
363                    "git",
364                    "coverage",
365                    "config scopes",
366                    "worktrees rebase",
367                    "worktrees push",
368                    "ai jev route",
369                    "ai jev verify-decision",
370                ],
371            ),
372            (
373                "instance",
374                vec![
375                    "atlassian jira",
376                    "atlassian confluence",
377                    "atlassian auth status",
378                ],
379            ),
380        ];
381
382        for (flag, owners) in &expected {
383            let mut matched = 0;
384            for (path, longs) in &all {
385                let accepts = longs.iter().any(|l| l == flag);
386                let should = under(path, owners);
387                assert_eq!(
388                    accepts,
389                    should,
390                    "`{path}` {} `--{flag}`, but the placement table says it {}",
391                    if accepts { "accepts" } else { "rejects" },
392                    if should {
393                        "reads it"
394                    } else {
395                        "does not read it"
396                    },
397                );
398                matched += usize::from(accepts);
399            }
400            assert!(matched > 0, "no command accepts `--{flag}`");
401        }
402
403        // `--profile` stays global: every leaf accepts it.
404        for (path, longs) in &all {
405            assert!(
406                longs.iter().any(|l| l == "profile"),
407                "`{path}` should accept the global `--profile`"
408            );
409        }
410
411        // Every `global = true` flag declared anywhere in the (un-built) tree,
412        // pinned by the exact node that declares it — so a new global, under
413        // any name, cannot appear without being added here on purpose.
414        use std::collections::BTreeSet;
415        fn declared_globals(cmd: &clap::Command, path: &str, out: &mut BTreeSet<String>) {
416            for arg in cmd.get_arguments().filter(|a| a.is_global_set()) {
417                let long = arg.get_long().unwrap_or_else(|| arg.get_id().as_str());
418                out.insert(format!("{path} --{long}"));
419            }
420            for sub in cmd.get_subcommands() {
421                declared_globals(sub, &format!("{path} {}", sub.get_name()), out);
422            }
423        }
424        let mut declared = BTreeSet::new();
425        declared_globals(&Cli::command(), "omni-dev", &mut declared);
426        let mut expected_globals: BTreeSet<String> = [
427            "omni-dev --profile",
428            "omni-dev git --repo",
429            "omni-dev coverage --repo",
430            "omni-dev config scopes --repo",
431            // `RepoArg` is `global = true` even on this leaf (a no-op there).
432            "omni-dev ai jev route --repo",
433            "omni-dev ai jev verify-decision --repo",
434            "omni-dev atlassian jira --instance",
435            "omni-dev atlassian confluence --instance",
436            "omni-dev atlassian auth status --instance",
437            // Pre-#1778 subtree globals, scoped the same way.
438            "omni-dev gmail --account",
439            "omni-dev drive --account",
440        ]
441        .into_iter()
442        .map(str::to_string)
443        .collect();
444        if cfg!(unix) {
445            // `RepoArg` is `global = true` even on these leaves (a no-op there).
446            expected_globals.insert("omni-dev worktrees rebase --repo".to_string());
447            expected_globals.insert("omni-dev worktrees push --repo".to_string());
448        }
449        assert_eq!(
450            declared, expected_globals,
451            "the set of `global = true` flags changed — apply the placement rule \
452             (#1778) and update this list"
453        );
454    }
455
456    /// Generalises the #1420 audit: no subcommand anywhere in the tree may
457    /// define an arg whose id collides with a root `global = true` arg. The
458    /// failure mode is silent at parse time and only surfaces as a downcast
459    /// panic when the global is read, so it is worth pinning structurally.
460    ///
461    /// Walks the **un-built** `Command`: `Command::build` propagates the globals
462    /// into every subcommand, which would make the check vacuously pass.
463    #[test]
464    fn no_subcommand_arg_shadows_a_global_arg_id() {
465        use clap::CommandFactory;
466        use std::collections::HashSet;
467
468        // A global arg isn't only declared at the root (`--profile`,
469        // `--instance`, …) — a subcommand can scope its own `global = true`
470        // arg to just its subtree (e.g. `gmail`'s `--account`, inherited by
471        // every `gmail` subcommand but not by sibling top-level commands
472        // like `snowflake`). So globals accumulate as the walk descends,
473        // not just once at the root.
474        //
475        // Two distinct clap failure modes share this same root cause and
476        // are both checked here:
477        // - id collision: a subcommand-local arg id equal to an inherited
478        //   global's id silently shadows it at read time instead of
479        //   erroring (#1420).
480        // - long-flag collision: two args bound to the same `--flag`
481        //   string on one effective command is a hard clap panic
482        //   ("Long option names must be unique for each argument") — this
483        //   is the *actual* constraint; a differently-named id with the
484        //   same `long` still collides.
485        fn walk(
486            cmd: &clap::Command,
487            inherited_ids: &HashSet<String>,
488            inherited_longs: &HashSet<String>,
489            path: &str,
490        ) {
491            let mut ids = inherited_ids.clone();
492            let mut longs = inherited_longs.clone();
493            for arg in cmd.get_arguments().filter(|a| a.is_global_set()) {
494                ids.insert(arg.get_id().as_str().to_string());
495                if let Some(long) = arg.get_long() {
496                    longs.insert(long.to_string());
497                }
498            }
499
500            for sub in cmd.get_subcommands() {
501                let sub_path = format!("{path} {}", sub.get_name());
502                for arg in sub.get_arguments() {
503                    let id = arg.get_id().as_str();
504                    assert!(
505                        !ids.contains(id),
506                        "`{sub_path}` defines an arg with id `{id}`, which is an \
507                         inherited global arg id — clap propagates globals by id, \
508                         so this shadows the global and panics when it is read \
509                         (#1420). Rename the subcommand-local field.",
510                    );
511                    if let Some(long) = arg.get_long() {
512                        assert!(
513                            !longs.contains(long),
514                            "`{sub_path}` defines `--{long}`, already an inherited \
515                             global flag — clap rejects two args on the same \
516                             command sharing a long flag name (issue #1500). \
517                             Rename the subcommand-local flag.",
518                        );
519                    }
520                }
521                walk(sub, &ids, &longs, &sub_path);
522            }
523        }
524
525        let cmd = Cli::command();
526        let root_globals: HashSet<String> = cmd
527            .get_arguments()
528            .filter(|a| a.is_global_set())
529            .map(|a| a.get_id().as_str().to_string())
530            .collect();
531        assert!(
532            !root_globals.is_empty(),
533            "expected the root command to declare global args"
534        );
535        walk(&cmd, &HashSet::new(), &HashSet::new(), "omni-dev");
536    }
537
538    // ── propagate_profile_flag() tests ──
539    //
540    // These tests mutate `OMNI_DEV_PROFILE`, which `execute()` reads (via
541    // `validate_active_profile`) in the `execute_routes_*` tests above. Those
542    // hold `HOME_ENV_MUTEX` (through the Gmail/Drive `EnvGuard`s), so these
543    // serialise on the same mutex — otherwise a profile set here makes a
544    // concurrent routing test fail with "unknown profile".
545
546    const PROFILE_VAR: &str = "OMNI_DEV_PROFILE";
547
548    /// Locks the shared mutex and snapshots/restores every env var
549    /// `propagate_profile_flag` may touch.
550    struct ProfileEnvGuard {
551        _lock: std::sync::MutexGuard<'static, ()>,
552        saved: [(&'static str, Option<String>); 1],
553    }
554
555    impl ProfileEnvGuard {
556        fn new() -> Self {
557            let lock = crate::test_support::HOME_ENV_MUTEX
558                .lock()
559                .unwrap_or_else(std::sync::PoisonError::into_inner);
560            let names = [PROFILE_VAR];
561            let saved = names.map(|n| (n, std::env::var(n).ok()));
562            for (n, _) in &saved {
563                std::env::remove_var(n);
564            }
565            Self { _lock: lock, saved }
566        }
567    }
568
569    impl Drop for ProfileEnvGuard {
570        fn drop(&mut self) {
571            for (n, value) in &self.saved {
572                match value {
573                    Some(v) => std::env::set_var(n, v),
574                    None => std::env::remove_var(n),
575                }
576            }
577        }
578    }
579
580    fn cli_with_defaults() -> Cli {
581        Cli::try_parse_from(["omni-dev", "help-all"]).unwrap()
582    }
583
584    #[test]
585    fn propagate_profile_flag_defaults_set_nothing() {
586        let _g = ProfileEnvGuard::new();
587        cli_with_defaults().propagate_profile_flag();
588        assert!(std::env::var(PROFILE_VAR).is_err());
589    }
590
591    #[test]
592    fn parses_profile_flag() {
593        let cli = Cli::try_parse_from(["omni-dev", "--profile", "work", "help-all"]).unwrap();
594        assert_eq!(cli.profile.as_deref(), Some("work"));
595    }
596
597    #[test]
598    fn profile_absent_is_none() {
599        let cli = Cli::try_parse_from(["omni-dev", "help-all"]).unwrap();
600        assert!(cli.profile.is_none());
601    }
602
603    #[test]
604    fn propagate_profile_flag_sets_profile() {
605        let _g = ProfileEnvGuard::new();
606        let mut cli = cli_with_defaults();
607        cli.profile = Some("work".to_string());
608        cli.propagate_profile_flag();
609        assert_eq!(std::env::var(PROFILE_VAR).ok().as_deref(), Some("work"));
610    }
611
612    #[test]
613    fn propagate_profile_flag_profile_flag_beats_env_var() {
614        let _g = ProfileEnvGuard::new();
615        std::env::set_var(PROFILE_VAR, "personal");
616        let mut cli = cli_with_defaults();
617        cli.profile = Some("work".to_string());
618        cli.propagate_profile_flag();
619        assert_eq!(std::env::var(PROFILE_VAR).ok().as_deref(), Some("work"));
620    }
621
622    #[test]
623    fn propagate_profile_flag_absent_profile_leaves_env_var() {
624        let _g = ProfileEnvGuard::new();
625        std::env::set_var(PROFILE_VAR, "personal");
626        cli_with_defaults().propagate_profile_flag();
627        assert_eq!(std::env::var(PROFILE_VAR).ok().as_deref(), Some("personal"));
628    }
629
630    // ── validate_active_profile() seam (pure: MapEnv + injected settings loader,
631    // no process env, no disk) ──
632
633    #[test]
634    fn validate_active_profile_ok_and_skips_load_when_no_profile() {
635        use crate::test_support::env::MapEnv;
636        let env = MapEnv::new();
637        let result = Cli::validate_active_profile(&env, || panic!("must not load settings"));
638        assert!(result.is_ok());
639    }
640
641    #[test]
642    fn validate_active_profile_ok_for_known_profile() {
643        use crate::test_support::env::MapEnv;
644        use crate::utils::settings::{Profile, Settings};
645        let env = MapEnv::new().with(PROFILE_VAR, "work");
646        let settings = Settings {
647            profiles: std::iter::once(("work".to_string(), Profile::default())).collect(),
648            ..Default::default()
649        };
650        assert!(Cli::validate_active_profile(&env, || settings).is_ok());
651    }
652
653    #[test]
654    fn validate_active_profile_errors_for_unknown_profile() {
655        use crate::test_support::env::MapEnv;
656        use crate::utils::settings::Settings;
657        let env = MapEnv::new().with(PROFILE_VAR, "wrok");
658        let err = Cli::validate_active_profile(&env, Settings::default)
659            .unwrap_err()
660            .to_string();
661        assert!(err.contains("unknown profile 'wrok'"));
662    }
663
664    #[test]
665    fn load_settings_or_default_never_panics() {
666        // The disk boundary must degrade to defaults rather than panic when
667        // `~/.omni-dev/settings.json` is absent or unreadable. Exercises the
668        // production loader directly, no process env or fixture required.
669        let _settings = Cli::load_settings_or_default();
670    }
671}