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, and merging sharded reports.
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 claude skills",
369                    "ai jev route",
370                    "ai jev exists",
371                    "ai jev verify-decision",
372                ],
373            ),
374            (
375                "instance",
376                vec![
377                    "atlassian jira",
378                    "atlassian confluence",
379                    "atlassian auth status",
380                ],
381            ),
382        ];
383
384        for (flag, owners) in &expected {
385            let mut matched = 0;
386            for (path, longs) in &all {
387                let accepts = longs.iter().any(|l| l == flag);
388                let should = under(path, owners);
389                assert_eq!(
390                    accepts,
391                    should,
392                    "`{path}` {} `--{flag}`, but the placement table says it {}",
393                    if accepts { "accepts" } else { "rejects" },
394                    if should {
395                        "reads it"
396                    } else {
397                        "does not read it"
398                    },
399                );
400                matched += usize::from(accepts);
401            }
402            assert!(matched > 0, "no command accepts `--{flag}`");
403        }
404
405        // `--profile` stays global: every leaf accepts it.
406        for (path, longs) in &all {
407            assert!(
408                longs.iter().any(|l| l == "profile"),
409                "`{path}` should accept the global `--profile`"
410            );
411        }
412
413        // Every `global = true` flag declared anywhere in the (un-built) tree,
414        // pinned by the exact node that declares it — so a new global, under
415        // any name, cannot appear without being added here on purpose.
416        use std::collections::BTreeSet;
417        fn declared_globals(cmd: &clap::Command, path: &str, out: &mut BTreeSet<String>) {
418            for arg in cmd.get_arguments().filter(|a| a.is_global_set()) {
419                let long = arg.get_long().unwrap_or_else(|| arg.get_id().as_str());
420                out.insert(format!("{path} --{long}"));
421            }
422            for sub in cmd.get_subcommands() {
423                declared_globals(sub, &format!("{path} {}", sub.get_name()), out);
424            }
425        }
426        let mut declared = BTreeSet::new();
427        declared_globals(&Cli::command(), "omni-dev", &mut declared);
428        let mut expected_globals: BTreeSet<String> = [
429            "omni-dev --profile",
430            "omni-dev git --repo",
431            "omni-dev coverage --repo",
432            "omni-dev config scopes --repo",
433            "omni-dev ai claude skills --repo",
434            // `RepoArg` is `global = true` even on this leaf (a no-op there).
435            "omni-dev ai jev route --repo",
436            "omni-dev ai jev exists --repo",
437            "omni-dev ai jev verify-decision --repo",
438            "omni-dev atlassian jira --instance",
439            "omni-dev atlassian confluence --instance",
440            "omni-dev atlassian auth status --instance",
441            // Pre-#1778 subtree globals, scoped the same way.
442            "omni-dev gmail --account",
443            "omni-dev drive --account",
444        ]
445        .into_iter()
446        .map(str::to_string)
447        .collect();
448        if cfg!(unix) {
449            // `RepoArg` is `global = true` even on these leaves (a no-op there).
450            expected_globals.insert("omni-dev worktrees rebase --repo".to_string());
451            expected_globals.insert("omni-dev worktrees push --repo".to_string());
452        }
453        assert_eq!(
454            declared, expected_globals,
455            "the set of `global = true` flags changed — apply the placement rule \
456             (#1778) and update this list"
457        );
458    }
459
460    /// Generalises the #1420 audit: no subcommand anywhere in the tree may
461    /// define an arg whose id collides with a root `global = true` arg. The
462    /// failure mode is silent at parse time and only surfaces as a downcast
463    /// panic when the global is read, so it is worth pinning structurally.
464    ///
465    /// Walks the **un-built** `Command`: `Command::build` propagates the globals
466    /// into every subcommand, which would make the check vacuously pass.
467    #[test]
468    fn no_subcommand_arg_shadows_a_global_arg_id() {
469        use clap::CommandFactory;
470        use std::collections::HashSet;
471
472        // A global arg isn't only declared at the root (`--profile`,
473        // `--instance`, …) — a subcommand can scope its own `global = true`
474        // arg to just its subtree (e.g. `gmail`'s `--account`, inherited by
475        // every `gmail` subcommand but not by sibling top-level commands
476        // like `snowflake`). So globals accumulate as the walk descends,
477        // not just once at the root.
478        //
479        // Two distinct clap failure modes share this same root cause and
480        // are both checked here:
481        // - id collision: a subcommand-local arg id equal to an inherited
482        //   global's id silently shadows it at read time instead of
483        //   erroring (#1420).
484        // - long-flag collision: two args bound to the same `--flag`
485        //   string on one effective command is a hard clap panic
486        //   ("Long option names must be unique for each argument") — this
487        //   is the *actual* constraint; a differently-named id with the
488        //   same `long` still collides.
489        fn walk(
490            cmd: &clap::Command,
491            inherited_ids: &HashSet<String>,
492            inherited_longs: &HashSet<String>,
493            path: &str,
494        ) {
495            let mut ids = inherited_ids.clone();
496            let mut longs = inherited_longs.clone();
497            for arg in cmd.get_arguments().filter(|a| a.is_global_set()) {
498                ids.insert(arg.get_id().as_str().to_string());
499                if let Some(long) = arg.get_long() {
500                    longs.insert(long.to_string());
501                }
502            }
503
504            for sub in cmd.get_subcommands() {
505                let sub_path = format!("{path} {}", sub.get_name());
506                for arg in sub.get_arguments() {
507                    let id = arg.get_id().as_str();
508                    assert!(
509                        !ids.contains(id),
510                        "`{sub_path}` defines an arg with id `{id}`, which is an \
511                         inherited global arg id — clap propagates globals by id, \
512                         so this shadows the global and panics when it is read \
513                         (#1420). Rename the subcommand-local field.",
514                    );
515                    if let Some(long) = arg.get_long() {
516                        assert!(
517                            !longs.contains(long),
518                            "`{sub_path}` defines `--{long}`, already an inherited \
519                             global flag — clap rejects two args on the same \
520                             command sharing a long flag name (issue #1500). \
521                             Rename the subcommand-local flag.",
522                        );
523                    }
524                }
525                walk(sub, &ids, &longs, &sub_path);
526            }
527        }
528
529        let cmd = Cli::command();
530        let root_globals: HashSet<String> = cmd
531            .get_arguments()
532            .filter(|a| a.is_global_set())
533            .map(|a| a.get_id().as_str().to_string())
534            .collect();
535        assert!(
536            !root_globals.is_empty(),
537            "expected the root command to declare global args"
538        );
539        walk(&cmd, &HashSet::new(), &HashSet::new(), "omni-dev");
540    }
541
542    // ── propagate_profile_flag() tests ──
543    //
544    // These tests mutate `OMNI_DEV_PROFILE`, which `execute()` reads (via
545    // `validate_active_profile`) in the `execute_routes_*` tests above. Those
546    // hold `HOME_ENV_MUTEX` (through the Gmail/Drive `EnvGuard`s), so these
547    // serialise on the same mutex — otherwise a profile set here makes a
548    // concurrent routing test fail with "unknown profile".
549
550    const PROFILE_VAR: &str = "OMNI_DEV_PROFILE";
551
552    /// Locks the shared mutex and snapshots/restores every env var
553    /// `propagate_profile_flag` may touch.
554    struct ProfileEnvGuard {
555        _lock: std::sync::MutexGuard<'static, ()>,
556        saved: [(&'static str, Option<String>); 1],
557    }
558
559    impl ProfileEnvGuard {
560        fn new() -> Self {
561            let lock = crate::test_support::HOME_ENV_MUTEX
562                .lock()
563                .unwrap_or_else(std::sync::PoisonError::into_inner);
564            let names = [PROFILE_VAR];
565            let saved = names.map(|n| (n, std::env::var(n).ok()));
566            for (n, _) in &saved {
567                std::env::remove_var(n);
568            }
569            Self { _lock: lock, saved }
570        }
571    }
572
573    impl Drop for ProfileEnvGuard {
574        fn drop(&mut self) {
575            for (n, value) in &self.saved {
576                match value {
577                    Some(v) => std::env::set_var(n, v),
578                    None => std::env::remove_var(n),
579                }
580            }
581        }
582    }
583
584    fn cli_with_defaults() -> Cli {
585        Cli::try_parse_from(["omni-dev", "help-all"]).unwrap()
586    }
587
588    #[test]
589    fn propagate_profile_flag_defaults_set_nothing() {
590        let _g = ProfileEnvGuard::new();
591        cli_with_defaults().propagate_profile_flag();
592        assert!(std::env::var(PROFILE_VAR).is_err());
593    }
594
595    #[test]
596    fn parses_profile_flag() {
597        let cli = Cli::try_parse_from(["omni-dev", "--profile", "work", "help-all"]).unwrap();
598        assert_eq!(cli.profile.as_deref(), Some("work"));
599    }
600
601    #[test]
602    fn profile_absent_is_none() {
603        let cli = Cli::try_parse_from(["omni-dev", "help-all"]).unwrap();
604        assert!(cli.profile.is_none());
605    }
606
607    #[test]
608    fn propagate_profile_flag_sets_profile() {
609        let _g = ProfileEnvGuard::new();
610        let mut cli = cli_with_defaults();
611        cli.profile = Some("work".to_string());
612        cli.propagate_profile_flag();
613        assert_eq!(std::env::var(PROFILE_VAR).ok().as_deref(), Some("work"));
614    }
615
616    #[test]
617    fn propagate_profile_flag_profile_flag_beats_env_var() {
618        let _g = ProfileEnvGuard::new();
619        std::env::set_var(PROFILE_VAR, "personal");
620        let mut cli = cli_with_defaults();
621        cli.profile = Some("work".to_string());
622        cli.propagate_profile_flag();
623        assert_eq!(std::env::var(PROFILE_VAR).ok().as_deref(), Some("work"));
624    }
625
626    #[test]
627    fn propagate_profile_flag_absent_profile_leaves_env_var() {
628        let _g = ProfileEnvGuard::new();
629        std::env::set_var(PROFILE_VAR, "personal");
630        cli_with_defaults().propagate_profile_flag();
631        assert_eq!(std::env::var(PROFILE_VAR).ok().as_deref(), Some("personal"));
632    }
633
634    // ── validate_active_profile() seam (pure: MapEnv + injected settings loader,
635    // no process env, no disk) ──
636
637    #[test]
638    fn validate_active_profile_ok_and_skips_load_when_no_profile() {
639        use crate::test_support::env::MapEnv;
640        let env = MapEnv::new();
641        let result = Cli::validate_active_profile(&env, || panic!("must not load settings"));
642        assert!(result.is_ok());
643    }
644
645    #[test]
646    fn validate_active_profile_ok_for_known_profile() {
647        use crate::test_support::env::MapEnv;
648        use crate::utils::settings::{Profile, Settings};
649        let env = MapEnv::new().with(PROFILE_VAR, "work");
650        let settings = Settings {
651            profiles: std::iter::once(("work".to_string(), Profile::default())).collect(),
652            ..Default::default()
653        };
654        assert!(Cli::validate_active_profile(&env, || settings).is_ok());
655    }
656
657    #[test]
658    fn validate_active_profile_errors_for_unknown_profile() {
659        use crate::test_support::env::MapEnv;
660        use crate::utils::settings::Settings;
661        let env = MapEnv::new().with(PROFILE_VAR, "wrok");
662        let err = Cli::validate_active_profile(&env, Settings::default)
663            .unwrap_err()
664            .to_string();
665        assert!(err.contains("unknown profile 'wrok'"));
666    }
667
668    #[test]
669    fn load_settings_or_default_never_panics() {
670        // The disk boundary must degrade to defaults rather than panic when
671        // `~/.omni-dev/settings.json` is absent or unreadable. Exercises the
672        // production loader directly, no process env or fixture required.
673        let _settings = Cli::load_settings_or_default();
674    }
675}