Skip to main content

jan_cli/
lib.rs

1mod builtins;
2mod config;
3mod cron;
4mod deps;
5mod inputs;
6mod inspect;
7mod runner;
8mod spec_load;
9mod yaml_closure;
10
11pub use config::{load_user_config, UserConfig};
12pub use runner::run_jan;
13pub use spec_load::HostPlatform;
14
15use std::collections::BTreeMap;
16use std::ffi::OsString;
17use std::path::{Path, PathBuf};
18use std::process::Command;
19
20use anyhow::{bail, Context, Result};
21use rusqlite::Connection;
22use serde::Deserialize;
23use serde::de::{self, Deserializer, Visitor};
24use std::fmt;
25
26#[derive(Debug, Deserialize)]
27pub struct RootSpec {
28    pub metadata: Option<Metadata>,
29    #[serde(default)]
30    pub commands: BTreeMap<String, CommandNode>,
31}
32
33#[derive(Debug, Deserialize)]
34pub struct Metadata {
35    pub name: Option<String>,
36    pub description: Option<String>,
37}
38
39/// Child-process environment declaration for a command node.
40///
41/// Two YAML shapes are accepted:
42///
43/// ```yaml
44/// # Legacy / shorthand — all keys are public assignments
45/// env:
46///   FOO: bar
47///
48/// # Explicit sections
49/// env:
50///   public:
51///     FOO: bar
52///   private:
53///     - GH_TOKEN
54/// ```
55///
56/// `public` values are taken from the YAML. `private` names must already exist in
57/// jan's own environment; their values are copied into the child and never stored
58/// in the spec. When either section is non-empty, the child runs with a cleared
59/// environment containing only those variables plus a small essential allowlist
60/// (PATH, HOME, …).
61#[derive(Debug, Default, Clone, PartialEq, Eq)]
62pub struct EnvSpec {
63    pub public: BTreeMap<String, String>,
64    pub private: Vec<String>,
65}
66
67impl EnvSpec {
68    pub fn is_empty(&self) -> bool {
69        self.public.is_empty() && self.private.is_empty()
70    }
71
72    /// True when the child should not inherit the full parent environment.
73    pub fn restricts_child_env(&self) -> bool {
74        !self.is_empty()
75    }
76
77    pub fn merge_from(&mut self, other: EnvSpec) {
78        for (k, v) in other.public {
79            self.public.insert(k, v);
80        }
81        for name in other.private {
82            if !self.private.iter().any(|p| p == &name) {
83                self.private.push(name);
84            }
85        }
86    }
87}
88
89impl<'de> Deserialize<'de> for EnvSpec {
90    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
91    where
92        D: Deserializer<'de>,
93    {
94        #[derive(Deserialize)]
95        struct Structured {
96            #[serde(default)]
97            public: BTreeMap<String, String>,
98            #[serde(default, deserialize_with = "deserialize_string_or_seq")]
99            private: Vec<String>,
100        }
101
102        #[derive(Deserialize)]
103        #[serde(untagged)]
104        enum EnvDe {
105            Flat(BTreeMap<String, String>),
106            Sections(Structured),
107        }
108
109        Ok(match EnvDe::deserialize(deserializer)? {
110            EnvDe::Flat(public) => Self {
111                public,
112                private: Vec::new(),
113            },
114            EnvDe::Sections(s) => Self {
115                public: s.public,
116                private: s.private,
117            },
118        })
119    }
120}
121
122#[derive(Debug, Deserialize, Default, Clone)]
123pub struct CommandNode {
124    /// If non-empty, this command and its subtree are only offered on these
125    /// platforms (`linux`, `macos`, `windows`, …). `darwin` is accepted as an alias for `macos`.
126    #[serde(default)]
127    pub os: Vec<String>,
128    #[serde(default)]
129    pub about: String,
130    /// Directory prepended to PATH when this script (or a descendant leaf) runs.
131    pub path: Option<String>,
132    /// Other script names whose `path` directories are prepended before this one runs.
133    #[serde(default)]
134    pub dependencies: Vec<String>,
135    /// External binaries that must be on PATH (e.g. `fzf`, `jq`) before the leaf runs.
136    #[serde(default)]
137    pub requires: Vec<String>,
138    /// Public assignments and/or private names required from the host environment.
139    #[serde(default)]
140    pub env: EnvSpec,
141    /// Named CLI inputs (`--name value`) available as `${{ inputs.name }}` in env/argv.
142    #[serde(default)]
143    pub inputs: BTreeMap<String, crate::inputs::InputDef>,
144    /// Optional crontab schedule(s). When set, `jan cron` runs this script's `run`
145    /// leaf whenever the local time matches any expression.
146    #[serde(default, deserialize_with = "deserialize_string_or_seq")]
147    pub cron: Vec<String>,
148    #[serde(default)]
149    pub commands: BTreeMap<String, CommandNode>,
150    pub exec: Option<ExecSpec>,
151}
152
153pub(crate) fn deserialize_string_or_seq<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
154where
155    D: Deserializer<'de>,
156{
157    struct StringOrSeq;
158
159    impl<'de> Visitor<'de> for StringOrSeq {
160        type Value = Vec<String>;
161
162        fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
163            formatter.write_str("a string or a sequence of strings")
164        }
165
166        fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
167        where
168            E: de::Error,
169        {
170            if value.trim().is_empty() {
171                Ok(Vec::new())
172            } else {
173                Ok(vec![value.to_string()])
174            }
175        }
176
177        fn visit_string<E>(self, value: String) -> Result<Self::Value, E>
178        where
179            E: de::Error,
180        {
181            self.visit_str(&value)
182        }
183
184        fn visit_seq<A>(self, mut seq: A) -> Result<Self::Value, A::Error>
185        where
186            A: de::SeqAccess<'de>,
187        {
188            let mut out = Vec::new();
189            while let Some(s) = seq.next_element::<String>()? {
190                if !s.trim().is_empty() {
191                    out.push(s);
192                }
193            }
194            Ok(out)
195        }
196
197        fn visit_none<E>(self) -> Result<Self::Value, E>
198        where
199            E: de::Error,
200        {
201            Ok(Vec::new())
202        }
203
204        fn visit_unit<E>(self) -> Result<Self::Value, E>
205        where
206            E: de::Error,
207        {
208            Ok(Vec::new())
209        }
210    }
211
212    deserializer.deserialize_any(StringOrSeq)
213}
214
215#[derive(Debug, Deserialize, Clone)]
216pub struct ExecSpec {
217    /// Full argument vector; first element is the program.
218    pub argv: Vec<String>,
219    /// Append extra CLI arguments after those from `argv`.
220    #[serde(default)]
221    pub passthrough: bool,
222}
223
224impl CommandNode {
225    pub fn is_leaf_exec(&self) -> bool {
226        self.exec.is_some()
227    }
228
229    pub fn validate(&self, path: &str) -> Result<()> {
230        if self.exec.is_some() && !self.commands.is_empty() {
231            bail!("command '{path}' cannot define both `exec` and nested `commands`");
232        }
233        if let Some(ref e) = self.exec {
234            if e.argv.is_empty() {
235                bail!("command '{path}': exec.argv must not be empty");
236            }
237        }
238        for name in self.inputs.keys() {
239            inputs::InputDef::validate_name(name)
240                .map_err(|e| anyhow::anyhow!("command '{path}': {e}"))?;
241        }
242        for (name, child) in &self.commands {
243            let p = if path.is_empty() {
244                name.clone()
245            } else {
246                format!("{path} {name}")
247            };
248            child.validate(&p)?;
249        }
250        Ok(())
251    }
252}
253
254/// Deep-merge `overlay.commands` into `base`, letting included YAML fragments
255/// add or replace leaves and extend nested groups.
256pub fn merge_specs_into(base: &mut RootSpec, overlay: RootSpec) -> Result<()> {
257    for (name, node) in overlay.commands {
258        match base.commands.get_mut(&name) {
259            Some(existing) => merge_command_node(existing, node)?,
260            None => {
261                base.commands.insert(name, node);
262            }
263        }
264    }
265    Ok(())
266}
267
268fn merge_command_node(dst: &mut CommandNode, src: CommandNode) -> Result<()> {
269    if src.exec.is_some() && !src.commands.is_empty() {
270        bail!("merge overlay: command cannot define both `exec` and nested `commands`");
271    }
272    if !src.os.is_empty() {
273        dst.os = src.os;
274    }
275    if !src.about.trim().is_empty() {
276        dst.about = src.about;
277    }
278    if src.path.is_some() {
279        dst.path = src.path;
280    }
281    if !src.dependencies.is_empty() {
282        dst.dependencies = src.dependencies;
283    }
284    if !src.requires.is_empty() {
285        dst.requires = src.requires;
286    }
287    if !src.cron.is_empty() {
288        dst.cron = src.cron;
289    }
290    if !src.env.is_empty() {
291        dst.env.merge_from(src.env);
292    }
293    for (k, v) in src.inputs {
294        dst.inputs.insert(k, v);
295    }
296    if let Some(exec) = src.exec {
297        dst.exec = Some(exec);
298        dst.commands.clear();
299        return Ok(());
300    }
301    if !src.commands.is_empty() {
302        dst.exec = None;
303        for (k, child) in src.commands {
304            match dst.commands.get_mut(&k) {
305                Some(existing) => merge_command_node(existing, child)?,
306                None => {
307                    dst.commands.insert(k, child);
308                }
309            }
310        }
311    }
312    Ok(())
313}
314
315/// Validate every command in the tree (after merges or programmatic edits).
316pub fn validate_spec(spec: &RootSpec) -> Result<()> {
317    for (name, node) in &spec.commands {
318        node.validate(name)?;
319    }
320    Ok(())
321}
322
323/// Parse YAML from memory. Use `include_base` when the document uses `include:` (root or subtree).
324pub fn load_spec_from_str(raw: &str, include_base: Option<&Path>) -> Result<RootSpec> {
325    spec_load::load_spec_from_str(raw, include_base, HostPlatform::detect())
326}
327
328pub fn load_spec(path: &Path) -> Result<RootSpec> {
329    spec_load::load_spec_from_path(path, HostPlatform::detect())
330}
331
332pub fn resolve_git_branch(cwd: &Path, override_branch: Option<&str>) -> String {
333    if let Some(b) = override_branch {
334        if !b.is_empty() {
335            return b.to_string();
336        }
337    }
338    if let Ok(v) = std::env::var("JAN_BRANCH") {
339        if !v.is_empty() {
340            return v;
341        }
342    }
343    let output = Command::new("git")
344        .args(["rev-parse", "--abbrev-ref", "HEAD"])
345        .current_dir(cwd)
346        .output();
347    match output {
348        Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout).trim().to_string(),
349        _ => "(no-git)".to_string(),
350    }
351}
352
353fn first_line(s: &str) -> String {
354    s.lines().next().unwrap_or("").trim().to_string()
355}
356
357pub fn format_help(spec: &RootSpec, chain: &[String], node: Option<&CommandNode>) -> String {
358    let mut out = String::new();
359    let bin = spec
360        .metadata
361        .as_ref()
362        .and_then(|m| m.name.as_deref())
363        .unwrap_or("jan");
364    let full_cmd = if chain.is_empty() {
365        bin.to_string()
366    } else {
367        format!("{} {}", bin, chain.join(" "))
368    };
369
370    let (about, children, exec) = match node {
371        Some(n) => (n.about.as_str(), &n.commands, n.exec.as_ref()),
372        None => ("", &spec.commands, None),
373    };
374
375    if chain.is_empty() {
376        if let Some(meta) = &spec.metadata {
377            if let Some(desc) = &meta.description {
378                out.push_str(desc.trim());
379                out.push_str("\n\n");
380            }
381        }
382    }
383
384    if !about.is_empty() {
385        out.push_str(about.trim());
386        out.push_str("\n\n");
387    }
388
389    if exec.is_some() && children.is_empty() {
390        out.push_str("This command runs an external program (see spec `exec.argv`).\n");
391        let defs = inputs::collect_chain_inputs(chain, spec);
392        if !defs.is_empty() {
393            out.push('\n');
394            out.push_str(&inputs::format_inputs_help(&defs));
395        }
396        return out;
397    }
398
399    if !children.is_empty() {
400        out.push_str("Subcommands:\n");
401        for (name, child) in children {
402            let line = if child.about.is_empty() {
403                format!("  {name}\n")
404            } else {
405                format!("  {name} — {}\n", first_line(&child.about))
406            };
407            out.push_str(&line);
408        }
409        out.push('\n');
410        out.push_str(&format!(
411            "Use `{} --help` for more about a subcommand.\n",
412            full_cmd
413        ));
414        let defs = inputs::collect_chain_inputs(chain, spec);
415        if !defs.is_empty() {
416            out.push('\n');
417            out.push_str(&inputs::format_inputs_help(&defs));
418        }
419    } else if exec.is_none() {
420        out.push_str("(No subcommands defined.)\n");
421    }
422    if chain.is_empty() && node.is_none() {
423        out.push_str(
424            "\nPlace `--help` or `-h` right after the subcommand prefix you want. Built-ins: `use`, `bundle`, `alias`, `list`, `search`, `show`, `validate`, `audit`, `cron`.\n",
425        );
426    }
427    out
428}
429
430/// Stable identity for where a YAML spec tree is rooted (directory of linked fragments + entry file).
431#[derive(Debug, Clone)]
432pub struct SpecRootIdentity {
433    /// Canonical directory containing top-level YAML fragments.
434    pub spec_dir: String,
435    /// Entry YAML file name relative to `spec_dir`.
436    pub root_yaml: String,
437}
438
439/// Resolve the preferred jan directory saved by `jan use`.
440pub fn resolve_preferred_spec() -> Result<(PathBuf, SpecRootIdentity)> {
441    let cfg = config::load_user_config().context("load user config")?;
442    let Some(dir_s) = cfg
443        .jan_dir
444        .as_ref()
445        .map(|s| s.trim())
446        .filter(|s| !s.is_empty())
447    else {
448        bail!(
449            "no preferred jan directory configured\n\
450             Run `jan use <DIR>` to save a YAML command tree (see docs/PORTABLE_SCRIPTS.md)"
451        );
452    };
453    let dir = PathBuf::from(dir_s);
454    if !dir.is_dir() {
455        bail!(
456            "preferred jan directory does not exist: {}\n\
457             Fix the path or run `jan use <DIR>` again (config: {})",
458            dir.display(),
459            config::config_path().display()
460        );
461    }
462    let root = cfg
463        .spec_root
464        .as_deref()
465        .map(str::trim)
466        .filter(|s| !s.is_empty())
467        .unwrap_or("scripts.spec.yaml");
468    resolve_spec_dir_entry(&dir, root)
469}
470
471/// Resolve a jan directory + entry file name into an absolute spec path and identity.
472pub fn resolve_spec_dir_entry(
473    spec_dir: &Path,
474    root_yaml: &str,
475) -> Result<(PathBuf, SpecRootIdentity)> {
476    let rel = Path::new(root_yaml);
477    if rel.is_absolute() {
478        bail!("entry YAML must be a relative file name, not an absolute path");
479    }
480    if rel
481        .components()
482        .any(|c| matches!(c, std::path::Component::ParentDir))
483    {
484        bail!("entry YAML must not contain `..`");
485    }
486    let normal_only = rel
487        .components()
488        .all(|c| matches!(c, std::path::Component::Normal(_)));
489    let n = rel
490        .components()
491        .filter(|c| matches!(c, std::path::Component::Normal(_)))
492        .count();
493    if !normal_only || n != 1 {
494        bail!("entry YAML must be a single file name inside the jan directory");
495    }
496    let dir = spec_dir
497        .canonicalize()
498        .with_context(|| format!("canonicalize jan directory {}", spec_dir.display()))?;
499    if !dir.is_dir() {
500        bail!("not a directory: {}", dir.display());
501    }
502    let spec_path = dir.join(rel);
503    if !spec_path.is_file() {
504        bail!(
505            "spec entry not found: {} (under {})\n\
506             Expected a properly formatted jan directory (e.g. scripts.spec.yaml)",
507            spec_path.display(),
508            dir.display()
509        );
510    }
511    let identity = SpecRootIdentity {
512        spec_dir: dir.to_string_lossy().into_owned(),
513        root_yaml: rel
514            .file_name()
515            .expect("relative root has file_name")
516            .to_string_lossy()
517            .into_owned(),
518    };
519    Ok((spec_path, identity))
520}
521
522pub struct RunContext<'a> {
523    pub cwd: &'a Path,
524    pub db_path: Option<&'a Path>,
525    pub branch: String,
526    pub no_log: bool,
527    pub spec_root: &'a SpecRootIdentity,
528}
529
530pub fn run_matched(
531    spec: &RootSpec,
532    chain: &[String],
533    node: &CommandNode,
534    trailing: &[OsString],
535    ctx: &RunContext<'_>,
536) -> Result<i32> {
537    let exec = match &node.exec {
538        Some(e) => e,
539        None => {
540            let help = format_help(spec, chain, Some(node));
541            print!("{help}");
542            bail!("missing subcommand");
543        }
544    };
545    if exec.argv.is_empty() {
546        bail!("exec.argv must not be empty");
547    }
548
549    let input_defs = inputs::collect_chain_inputs(chain, spec);
550    let (input_vals, rest) = inputs::resolve_inputs(&input_defs, trailing)?;
551
552    let mut argv: Vec<String> = Vec::with_capacity(exec.argv.len());
553    for a in &exec.argv {
554        argv.push(inputs::interpolate(a, &input_vals)?);
555    }
556    if exec.passthrough {
557        for a in &rest {
558            argv.push(a.to_string_lossy().into_owned());
559        }
560    } else if !rest.is_empty() {
561        let preview = rest
562            .iter()
563            .take(3)
564            .map(|s| s.to_string_lossy().into_owned())
565            .collect::<Vec<_>>()
566            .join(" ");
567        bail!(
568            "unexpected trailing arguments: {preview}{}",
569            if rest.len() > 3 { "…" } else { "" }
570        );
571    }
572
573    let cmd_path = if chain.is_empty() {
574        "(root)".to_string()
575    } else {
576        chain.join(" ")
577    };
578
579    let (_, requires, _) = deps::collect_chain_metadata(chain, spec);
580    deps::check_requires(&requires)?;
581
582    let path_dirs = deps::resolve_path_prefixes(spec, chain, ctx)?;
583    let program = deps::resolve_program(&argv[0], &path_dirs)?;
584    let mut env_spec = deps::collect_chain_env(chain, spec);
585    for value in env_spec.public.values_mut() {
586        *value = inputs::interpolate(value, &input_vals)?;
587    }
588    deps::check_private_env(&env_spec.private)?;
589    let path_override = if !path_dirs.is_empty() {
590        Some(deps::prepend_path_env(&path_dirs)?)
591    } else {
592        None
593    };
594
595    let mut c = Command::new(&program);
596    if argv.len() > 1 {
597        c.args(&argv[1..]);
598    }
599    c.current_dir(ctx.cwd);
600    deps::apply_process_env(&mut c, &env_spec, path_override)?;
601
602    let status = c.status().with_context(|| format!("spawn `{}`", argv[0]))?;
603    let code = status.code().unwrap_or(255);
604
605    if !ctx.no_log {
606        if let Some(db) = ctx.db_path {
607            log_invocation(
608                db,
609                &ctx.branch,
610                ctx.cwd,
611                &cmd_path,
612                &argv,
613                code,
614                ctx.spec_root,
615            )?;
616        }
617    }
618
619    Ok(code)
620}
621
622fn ensure_invocations_spec_root_column(conn: &Connection) -> Result<()> {
623    let mut stmt = conn.prepare("PRAGMA table_info(invocations)")?;
624    let cols: Vec<String> = stmt
625        .query_map([], |row| row.get::<_, String>(1))?
626        .collect::<std::result::Result<_, _>>()?;
627    if !cols.iter().any(|c| c == "spec_root_id") {
628        conn.execute(
629            "ALTER TABLE invocations ADD COLUMN spec_root_id INTEGER",
630            [],
631        )?;
632    }
633    Ok(())
634}
635
636fn upsert_spec_root(conn: &Connection, spec: &SpecRootIdentity) -> Result<i64> {
637    let ts = unix_ts();
638    conn.execute(
639        r"INSERT INTO spec_roots (spec_dir, root_yaml, last_used_ts) VALUES (?1, ?2, ?3)
640          ON CONFLICT(spec_dir, root_yaml) DO UPDATE SET last_used_ts = excluded.last_used_ts",
641        rusqlite::params![&spec.spec_dir, &spec.root_yaml, &ts],
642    )?;
643    let id: i64 = conn.query_row(
644        "SELECT id FROM spec_roots WHERE spec_dir = ?1 AND root_yaml = ?2",
645        [&spec.spec_dir, &spec.root_yaml],
646        |r| r.get(0),
647    )?;
648    Ok(id)
649}
650
651fn log_invocation(
652    db_path: &Path,
653    branch: &str,
654    cwd: &Path,
655    command_path: &str,
656    argv: &[String],
657    exit_code: i32,
658    spec_root: &SpecRootIdentity,
659) -> Result<()> {
660    if let Some(parent) = db_path.parent() {
661        std::fs::create_dir_all(parent).ok();
662    }
663    let conn = Connection::open(db_path).with_context(|| format!("open {}", db_path.display()))?;
664    conn.execute_batch(
665        r"
666        CREATE TABLE IF NOT EXISTS spec_roots (
667            id INTEGER PRIMARY KEY AUTOINCREMENT,
668            spec_dir TEXT NOT NULL,
669            root_yaml TEXT NOT NULL,
670            last_used_ts TEXT NOT NULL,
671            UNIQUE(spec_dir, root_yaml)
672        );
673        CREATE TABLE IF NOT EXISTS invocations (
674            id INTEGER PRIMARY KEY AUTOINCREMENT,
675            ts TEXT NOT NULL,
676            git_branch TEXT NOT NULL,
677            cwd TEXT NOT NULL,
678            command_path TEXT NOT NULL,
679            argv_json TEXT NOT NULL,
680            exit_code INTEGER NOT NULL,
681            spec_root_id INTEGER
682        );
683        ",
684    )?;
685    ensure_invocations_spec_root_column(&conn)?;
686    let spec_root_id = upsert_spec_root(&conn, spec_root)?;
687    let ts = unix_ts();
688    let argv_json = serde_json::to_string(argv).unwrap_or_else(|_| "[]".to_string());
689    let cwd_s = cwd.to_string_lossy();
690    conn.execute(
691        "INSERT INTO invocations (ts, git_branch, cwd, command_path, argv_json, exit_code, spec_root_id)
692         VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
693        rusqlite::params![
694            ts,
695            branch,
696            cwd_s.as_ref(),
697            command_path,
698            argv_json,
699            exit_code,
700            spec_root_id
701        ],
702    )?;
703    Ok(())
704}
705
706fn unix_ts() -> String {
707    use std::time::SystemTime;
708    SystemTime::now()
709        .duration_since(std::time::UNIX_EPOCH)
710        .unwrap_or_default()
711        .as_secs()
712        .to_string()
713}
714
715#[derive(Debug)]
716pub struct MatchOutcome<'a> {
717    pub chain: Vec<String>,
718    pub node: Option<&'a CommandNode>,
719    pub trailing: Vec<OsString>,
720    pub wants_help: bool,
721}
722
723pub fn match_commands<'a>(spec: &'a RootSpec, args: &[OsString]) -> MatchOutcome<'a> {
724    let mut chain = Vec::new();
725    let mut node: Option<&'a CommandNode> = None;
726    let mut map: &BTreeMap<String, CommandNode> = &spec.commands;
727    let mut i = 0usize;
728    let len = args.len();
729    while i < len {
730        let raw = &args[i];
731        if raw == "--help" || raw == "-h" {
732            return MatchOutcome {
733                chain,
734                node,
735                trailing: args[i + 1..].to_vec(),
736                wants_help: true,
737            };
738        }
739        let key = raw.to_string_lossy();
740        if let Some(next) = map.get(key.as_ref()) {
741            chain.push(key.into_owned());
742            node = Some(next);
743            map = &next.commands;
744            i += 1;
745            continue;
746        }
747        break;
748    }
749    MatchOutcome {
750        chain,
751        node,
752        trailing: args[i..].to_vec(),
753        wants_help: false,
754    }
755}
756
757#[cfg(test)]
758mod tests {
759    use super::*;
760    use std::io::Write;
761
762    #[test]
763    fn examples_default_spec_validates() {
764        let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("examples/default.spec.yaml");
765        load_spec(&path).unwrap();
766    }
767
768    #[test]
769    fn merge_specs_adds_and_replaces_leaves() {
770        let mut base = load_spec_from_str(
771            r"
772commands:
773  a:
774    about: base
775    commands:
776      x:
777        about: old
778        exec:
779          argv: [echo, old]
780",
781            None,
782        )
783        .unwrap();
784        let overlay = load_spec_from_str(
785            r"
786commands:
787  a:
788    commands:
789      x:
790        about: new leaf
791        exec:
792          argv: [echo, new]
793  b:
794    about: added top
795    exec:
796      argv: [echo, b]
797",
798            None,
799        )
800        .unwrap();
801        merge_specs_into(&mut base, overlay).unwrap();
802        base.commands["a"].commands["x"].validate("a x").unwrap();
803        assert_eq!(
804            base.commands["a"].commands["x"].exec.as_ref().unwrap().argv,
805            vec!["echo", "new"]
806        );
807        assert_eq!(
808            base.commands["b"].exec.as_ref().unwrap().argv,
809            vec!["echo", "b"]
810        );
811    }
812
813    #[test]
814    fn validate_rejects_exec_with_children() {
815        let mut tmp = tempfile::NamedTempFile::with_suffix(".yaml").unwrap();
816        write!(
817            tmp,
818            r"
819commands:
820  x:
821    exec:
822      argv: [echo]
823    commands:
824      child:
825        about: nested
826"
827        )
828        .unwrap();
829        let err = load_spec(tmp.path()).unwrap_err();
830        assert!(err.to_string().contains("cannot define both"));
831    }
832}
833
834pub fn default_db_path() -> PathBuf {
835    if let Ok(p) = std::env::var("JAN_DB") {
836        return PathBuf::from(p);
837    }
838    dirs::data_local_dir()
839        .unwrap_or_else(|| PathBuf::from("."))
840        .join("jan-cli")
841        .join("audit.db")
842}