Skip to main content

githops_core/
config.rs

1use anyhow::{Context, Result};
2use schemars::JsonSchema;
3use serde::{Deserialize, Serialize};
4use std::collections::BTreeMap;
5use std::path::{Path, PathBuf};
6
7pub const CONFIG_FILE: &str = "githops.yaml";
8pub const SCHEMA_FILE: &str = ".githops/githops.schema.json";
9
10/// The JSON Schema for `githops.yaml`, embedded at compile time.
11/// Use [`write_schema`] to materialize it on disk.
12pub const SCHEMA_JSON: &str = include_str!("../githops.schema.json");
13
14/// Write the embedded JSON Schema to `<dir>/.githops/githops.schema.json` if it is
15/// absent or differs from the embedded version.
16pub fn write_schema(dir: &std::path::Path) -> anyhow::Result<()> {
17    let githops_dir = dir.join(".githops");
18    std::fs::create_dir_all(&githops_dir)?;
19    let path = dir.join(SCHEMA_FILE);
20    let needs_write = match std::fs::read_to_string(&path) {
21        Ok(existing) => existing != SCHEMA_JSON,
22        Err(_) => true,
23    };
24    if needs_write {
25        std::fs::write(&path, SCHEMA_JSON)?;
26    }
27    Ok(())
28}
29
30/// Supported formats for included external config files.
31#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
32#[serde(rename_all = "lowercase")]
33pub enum IncludeType {
34    /// JSON file (e.g. `package.json`)
35    Json,
36    /// TOML file (e.g. `Cargo.toml`)
37    Toml,
38    /// YAML file (e.g. `scripts.yaml`)
39    Yaml,
40}
41
42/// An external file to import scripts from.
43#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
44pub struct LocalInclude {
45    /// Path to the file, relative to the repository root.
46    pub path: String,
47    /// File format.
48    #[serde(rename = "type")]
49    pub file_type: IncludeType,
50    /// Identifier used to reference this include in hook commands (`$include: <ref>`).
51    #[serde(rename = "ref")]
52    pub ref_name: String,
53}
54
55/// A remote file fetched via HTTP/HTTPS.
56#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
57pub struct RemoteInclude {
58    /// Full URL of the file to fetch.
59    pub url: String,
60    /// File format. Defaults to `yaml`.
61    #[serde(rename = "type", default = "default_yaml_type")]
62    pub file_type: IncludeType,
63    /// Identifier used to reference this include in hook commands (`$include: <ref>`).
64    #[serde(rename = "ref")]
65    pub ref_name: String,
66}
67
68fn default_yaml_type() -> IncludeType {
69    IncludeType::Yaml
70}
71
72/// A file sourced from a remote Git repository at a specific revision.
73#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
74pub struct GitInclude {
75    /// URL of the git repository (e.g. `https://github.com/org/repo.git`).
76    pub url: String,
77    /// Git revision to check out: branch name, tag, or full commit SHA.
78    pub rev: String,
79    /// Path of the file within the repository (e.g. `ci/scripts.yaml`).
80    pub file: String,
81    /// File format. Defaults to `yaml`.
82    #[serde(rename = "type", default = "default_yaml_type")]
83    pub file_type: IncludeType,
84    /// Identifier used to reference this include in hook commands (`$include: <ref>`).
85    #[serde(rename = "ref")]
86    pub ref_name: String,
87}
88
89/// An entry in the `include` list. Supports `local` (filesystem), `remote` (HTTP/HTTPS),
90/// and `git` (file from a remote Git repository) sources.
91///
92/// Example:
93/// ```yaml
94/// include:
95///   - source: local
96///     path: package.json
97///     type: json
98///     ref: packagejson
99///   - source: remote
100///     url: 'https://example.com/scripts.yaml'
101///     ref: sharedscripts
102///   - source: git
103///     url: 'https://github.com/org/repo.git'
104///     rev: main
105///     file: 'ci/scripts.yaml'
106///     ref: repotemplate
107/// ```
108#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
109#[serde(tag = "source", rename_all = "lowercase")]
110pub enum IncludeEntry {
111    /// A local file relative to the repository root.
112    Local(LocalInclude),
113    /// A remote file fetched via HTTP/HTTPS.
114    Remote(RemoteInclude),
115    /// A file sourced from a remote Git repository.
116    Git(GitInclude),
117}
118
119impl IncludeEntry {
120    pub fn ref_name(&self) -> &str {
121        match self {
122            IncludeEntry::Local(l) => &l.ref_name,
123            IncludeEntry::Remote(r) => &r.ref_name,
124            IncludeEntry::Git(g) => &g.ref_name,
125        }
126    }
127    pub fn path(&self) -> &str {
128        match self {
129            IncludeEntry::Local(l) => &l.path,
130            IncludeEntry::Remote(r) => &r.url,
131            IncludeEntry::Git(g) => &g.file,
132        }
133    }
134    pub fn file_type(&self) -> &IncludeType {
135        match self {
136            IncludeEntry::Local(l) => &l.file_type,
137            IncludeEntry::Remote(r) => &r.file_type,
138            IncludeEntry::Git(g) => &g.file_type,
139        }
140    }
141}
142
143#[derive(Debug, Default, Serialize, Deserialize, JsonSchema)]
144#[serde(rename_all = "kebab-case")]
145pub struct Config {
146    /// Schema version
147    #[serde(default = "default_version")]
148    pub version: String,
149
150    /// External file includes. Import scripts from `package.json`, `Cargo.toml`, etc.
151    ///
152    /// Defines named references to external files. Use `$include:` in hook commands to
153    /// reference them.
154    ///
155    /// Example:
156    /// ```yaml
157    /// include:
158    ///   - local:
159    ///       path: package.json
160    ///       type: json
161    ///       ref: packagejson
162    ///
163    /// hooks:
164    ///   pre-commit:
165    ///     commands:
166    ///       - $include: packagejson
167    ///         args: scripts.lint
168    /// ```
169    #[serde(default, skip_serializing_if = "Vec::is_empty")]
170    pub include: Vec<IncludeEntry>,
171
172    /// Reusable command definitions for YAML anchors.
173    ///
174    /// Define command templates here using YAML anchors (&name), then
175    /// reference them with YAML aliases (*name) inside hook command lists.
176    /// A list alias is automatically flattened into the parent sequence.
177    ///
178    /// Single command anchor:
179    /// ```yaml
180    /// definitions:
181    ///   lint: &lint
182    ///     name: lint
183    ///     run: cargo clippy -- -D warnings
184    /// ```
185    ///
186    /// List-of-commands anchor:
187    /// ```yaml
188    /// definitions:
189    ///   quality: &quality
190    ///     - name: lint
191    ///       run: cargo clippy
192    ///     - name: audit
193    ///       run: cargo audit
194    /// ```
195    ///
196    /// Usage in hooks (list aliases are inlined automatically):
197    /// ```yaml
198    /// hooks:
199    ///   pre-commit:
200    ///     commands:
201    ///       - name: fmt
202    ///         run: cargo fmt --check
203    ///       - *lint        # single command
204    ///       - *quality     # expands to two commands inline
205    /// ```
206    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
207    pub definitions: BTreeMap<String, DefinitionEntry>,
208
209    /// Hook definitions. Keys are git hook names (e.g. pre-commit, commit-msg).
210    #[serde(default)]
211    pub hooks: Hooks,
212
213    /// Smart caching — skip commands whose inputs haven't changed.
214    ///
215    /// Set `enabled: true` here, then add a `cache.inputs` list to each
216    /// command you want to cache.  The cache key is a SHA-256 of the command
217    /// script, any extra `cache.key` strings, and the content of every input
218    /// file.  A cache hit causes the command to be skipped with a "cached"
219    /// message.  Cache entries are stored in `.githops/cache/` (or `cache.dir`).
220    ///
221    /// Example:
222    /// ```yaml
223    /// cache:
224    ///   enabled: true
225    ///
226    /// hooks:
227    ///   pre-commit:
228    ///     commands:
229    ///       - name: lint
230    ///         run: cargo clippy -- -D warnings
231    ///         cache:
232    ///           inputs: ["src/**/*.rs", "Cargo.toml"]
233    ///       - name: test
234    ///         run: cargo test
235    ///         cache:
236    ///           inputs: ["src/**/*.rs", "tests/**/*.rs"]
237    ///           key: ["$RUST_TOOLCHAIN"]
238    /// ```
239    #[serde(default, skip_serializing_if = "GlobalCache::is_unconfigured")]
240    pub cache: GlobalCache,
241}
242
243/// Global cache settings.
244#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema)]
245#[serde(rename_all = "kebab-case")]
246pub struct GlobalCache {
247    /// Enable caching.  Commands without a `cache` block are always executed.
248    #[serde(default)]
249    pub enabled: bool,
250
251    /// Override the cache directory (default: `.githops/cache`).
252    #[serde(default, skip_serializing_if = "Option::is_none")]
253    pub dir: Option<String>,
254}
255
256impl GlobalCache {
257    pub fn is_unconfigured(&self) -> bool {
258        !self.enabled && self.dir.is_none()
259    }
260
261    pub fn cache_dir(&self) -> std::path::PathBuf {
262        std::path::PathBuf::from(
263            self.dir.as_deref().unwrap_or(".githops/cache"),
264        )
265    }
266}
267
268/// Per-command cache configuration.
269#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
270#[serde(rename_all = "kebab-case")]
271pub struct CommandCache {
272    /// File glob patterns treated as inputs.
273    /// The command is re-run only when the content of any matching file changes.
274    /// Globs are relative to the repository root.  Example: `["src/**/*.rs", "Cargo.toml"]`
275    #[serde(default)]
276    pub inputs: Vec<String>,
277
278    /// Extra strings mixed into the cache key (e.g. environment variable values
279    /// or tool version strings).  Example: `["$RUST_TOOLCHAIN"]`
280    #[serde(default, skip_serializing_if = "Vec::is_empty")]
281    pub key: Vec<String>,
282}
283
284fn default_version() -> String {
285    "1".to_string()
286}
287
288/// A reusable command definition: a single command mapping or a list of commands.
289#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
290#[serde(untagged)]
291pub enum DefinitionEntry {
292    /// A list of commands that will be inlined when the anchor is used.
293    List(Vec<Command>),
294    /// A single command.
295    Single(Command),
296}
297
298/// All supported git hooks. Configure any hook by adding its name as a key.
299#[derive(Debug, Default, Serialize, Deserialize, JsonSchema)]
300#[serde(rename_all = "kebab-case")]
301pub struct Hooks {
302    #[serde(skip_serializing_if = "Option::is_none")]
303    pub applypatch_msg: Option<HookConfig>,
304
305    #[serde(skip_serializing_if = "Option::is_none")]
306    pub pre_applypatch: Option<HookConfig>,
307
308    #[serde(skip_serializing_if = "Option::is_none")]
309    pub post_applypatch: Option<HookConfig>,
310
311    #[serde(skip_serializing_if = "Option::is_none")]
312    pub pre_commit: Option<HookConfig>,
313
314    #[serde(skip_serializing_if = "Option::is_none")]
315    pub pre_merge_commit: Option<HookConfig>,
316
317    #[serde(skip_serializing_if = "Option::is_none")]
318    pub prepare_commit_msg: Option<HookConfig>,
319
320    #[serde(skip_serializing_if = "Option::is_none")]
321    pub commit_msg: Option<HookConfig>,
322
323    #[serde(skip_serializing_if = "Option::is_none")]
324    pub post_commit: Option<HookConfig>,
325
326    #[serde(skip_serializing_if = "Option::is_none")]
327    pub pre_rebase: Option<HookConfig>,
328
329    #[serde(skip_serializing_if = "Option::is_none")]
330    pub post_checkout: Option<HookConfig>,
331
332    #[serde(skip_serializing_if = "Option::is_none")]
333    pub post_merge: Option<HookConfig>,
334
335    #[serde(skip_serializing_if = "Option::is_none")]
336    pub pre_push: Option<HookConfig>,
337
338    #[serde(skip_serializing_if = "Option::is_none")]
339    pub pre_receive: Option<HookConfig>,
340
341    #[serde(skip_serializing_if = "Option::is_none")]
342    pub update: Option<HookConfig>,
343
344    #[serde(skip_serializing_if = "Option::is_none")]
345    pub proc_receive: Option<HookConfig>,
346
347    #[serde(skip_serializing_if = "Option::is_none")]
348    pub post_receive: Option<HookConfig>,
349
350    #[serde(skip_serializing_if = "Option::is_none")]
351    pub post_update: Option<HookConfig>,
352
353    #[serde(skip_serializing_if = "Option::is_none")]
354    pub reference_transaction: Option<HookConfig>,
355
356    #[serde(skip_serializing_if = "Option::is_none")]
357    pub push_to_checkout: Option<HookConfig>,
358
359    #[serde(skip_serializing_if = "Option::is_none")]
360    pub pre_auto_gc: Option<HookConfig>,
361
362    #[serde(skip_serializing_if = "Option::is_none")]
363    pub post_rewrite: Option<HookConfig>,
364
365    #[serde(skip_serializing_if = "Option::is_none")]
366    pub sendemail_validate: Option<HookConfig>,
367
368    #[serde(skip_serializing_if = "Option::is_none")]
369    pub fsmonitor_watchman: Option<HookConfig>,
370
371    #[serde(skip_serializing_if = "Option::is_none")]
372    pub p4_changelist: Option<HookConfig>,
373
374    #[serde(skip_serializing_if = "Option::is_none")]
375    pub p4_prepare_changelist: Option<HookConfig>,
376
377    #[serde(skip_serializing_if = "Option::is_none")]
378    pub p4_post_changelist: Option<HookConfig>,
379
380    #[serde(skip_serializing_if = "Option::is_none")]
381    pub p4_pre_submit: Option<HookConfig>,
382
383    #[serde(skip_serializing_if = "Option::is_none")]
384    pub post_index_change: Option<HookConfig>,
385}
386
387impl Hooks {
388    /// Get a hook config by its git hook name (e.g. "pre-commit").
389    pub fn get(&self, name: &str) -> Option<&HookConfig> {
390        match name {
391            "applypatch-msg" => self.applypatch_msg.as_ref(),
392            "pre-applypatch" => self.pre_applypatch.as_ref(),
393            "post-applypatch" => self.post_applypatch.as_ref(),
394            "pre-commit" => self.pre_commit.as_ref(),
395            "pre-merge-commit" => self.pre_merge_commit.as_ref(),
396            "prepare-commit-msg" => self.prepare_commit_msg.as_ref(),
397            "commit-msg" => self.commit_msg.as_ref(),
398            "post-commit" => self.post_commit.as_ref(),
399            "pre-rebase" => self.pre_rebase.as_ref(),
400            "post-checkout" => self.post_checkout.as_ref(),
401            "post-merge" => self.post_merge.as_ref(),
402            "pre-push" => self.pre_push.as_ref(),
403            "pre-receive" => self.pre_receive.as_ref(),
404            "update" => self.update.as_ref(),
405            "proc-receive" => self.proc_receive.as_ref(),
406            "post-receive" => self.post_receive.as_ref(),
407            "post-update" => self.post_update.as_ref(),
408            "reference-transaction" => self.reference_transaction.as_ref(),
409            "push-to-checkout" => self.push_to_checkout.as_ref(),
410            "pre-auto-gc" => self.pre_auto_gc.as_ref(),
411            "post-rewrite" => self.post_rewrite.as_ref(),
412            "sendemail-validate" => self.sendemail_validate.as_ref(),
413            "fsmonitor-watchman" => self.fsmonitor_watchman.as_ref(),
414            "p4-changelist" => self.p4_changelist.as_ref(),
415            "p4-prepare-changelist" => self.p4_prepare_changelist.as_ref(),
416            "p4-post-changelist" => self.p4_post_changelist.as_ref(),
417            "p4-pre-submit" => self.p4_pre_submit.as_ref(),
418            "post-index-change" => self.post_index_change.as_ref(),
419            _ => None,
420        }
421    }
422
423    /// Set a hook config by its git hook name.
424    pub fn set(&mut self, name: &str, cfg: HookConfig) {
425        match name {
426            "applypatch-msg" => self.applypatch_msg = Some(cfg),
427            "pre-applypatch" => self.pre_applypatch = Some(cfg),
428            "post-applypatch" => self.post_applypatch = Some(cfg),
429            "pre-commit" => self.pre_commit = Some(cfg),
430            "pre-merge-commit" => self.pre_merge_commit = Some(cfg),
431            "prepare-commit-msg" => self.prepare_commit_msg = Some(cfg),
432            "commit-msg" => self.commit_msg = Some(cfg),
433            "post-commit" => self.post_commit = Some(cfg),
434            "pre-rebase" => self.pre_rebase = Some(cfg),
435            "post-checkout" => self.post_checkout = Some(cfg),
436            "post-merge" => self.post_merge = Some(cfg),
437            "pre-push" => self.pre_push = Some(cfg),
438            "pre-receive" => self.pre_receive = Some(cfg),
439            "update" => self.update = Some(cfg),
440            "proc-receive" => self.proc_receive = Some(cfg),
441            "post-receive" => self.post_receive = Some(cfg),
442            "post-update" => self.post_update = Some(cfg),
443            "reference-transaction" => self.reference_transaction = Some(cfg),
444            "push-to-checkout" => self.push_to_checkout = Some(cfg),
445            "pre-auto-gc" => self.pre_auto_gc = Some(cfg),
446            "post-rewrite" => self.post_rewrite = Some(cfg),
447            "sendemail-validate" => self.sendemail_validate = Some(cfg),
448            "fsmonitor-watchman" => self.fsmonitor_watchman = Some(cfg),
449            "p4-changelist" => self.p4_changelist = Some(cfg),
450            "p4-prepare-changelist" => self.p4_prepare_changelist = Some(cfg),
451            "p4-post-changelist" => self.p4_post_changelist = Some(cfg),
452            "p4-pre-submit" => self.p4_pre_submit = Some(cfg),
453            "post-index-change" => self.post_index_change = Some(cfg),
454            _ => {}
455        }
456    }
457
458    /// Remove a hook from the config by its git hook name.
459    pub fn remove(&mut self, name: &str) {
460        match name {
461            "applypatch-msg" => self.applypatch_msg = None,
462            "pre-applypatch" => self.pre_applypatch = None,
463            "post-applypatch" => self.post_applypatch = None,
464            "pre-commit" => self.pre_commit = None,
465            "pre-merge-commit" => self.pre_merge_commit = None,
466            "prepare-commit-msg" => self.prepare_commit_msg = None,
467            "commit-msg" => self.commit_msg = None,
468            "post-commit" => self.post_commit = None,
469            "pre-rebase" => self.pre_rebase = None,
470            "post-checkout" => self.post_checkout = None,
471            "post-merge" => self.post_merge = None,
472            "pre-push" => self.pre_push = None,
473            "pre-receive" => self.pre_receive = None,
474            "update" => self.update = None,
475            "proc-receive" => self.proc_receive = None,
476            "post-receive" => self.post_receive = None,
477            "post-update" => self.post_update = None,
478            "reference-transaction" => self.reference_transaction = None,
479            "push-to-checkout" => self.push_to_checkout = None,
480            "pre-auto-gc" => self.pre_auto_gc = None,
481            "post-rewrite" => self.post_rewrite = None,
482            "sendemail-validate" => self.sendemail_validate = None,
483            "fsmonitor-watchman" => self.fsmonitor_watchman = None,
484            "p4-changelist" => self.p4_changelist = None,
485            "p4-prepare-changelist" => self.p4_prepare_changelist = None,
486            "p4-post-changelist" => self.p4_post_changelist = None,
487            "p4-pre-submit" => self.p4_pre_submit = None,
488            "post-index-change" => self.post_index_change = None,
489            _ => {}
490        }
491    }
492}
493
494#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
495pub struct HookConfig {
496    /// Whether this hook is active. Set to false to temporarily disable.
497    #[serde(default = "default_true")]
498    pub enabled: bool,
499
500    /// Run commands concurrently within each dependency wave.
501    ///
502    /// When `true`, commands that have no dependency relationship are started at
503    /// the same time in separate threads.  Commands that share a `depends` link
504    /// are still serialised — the dependent command waits until all its
505    /// dependencies finish successfully.
506    ///
507    /// Use this to speed up independent checks (e.g. lint + test) while keeping
508    /// ordered steps (e.g. build → deploy) sequential.
509    ///
510    /// Example:
511    /// ```yaml
512    /// hooks:
513    ///   pre-push:
514    ///     parallel: true
515    ///     commands:
516    ///       - name: lint
517    ///         run: cargo clippy
518    ///       - name: test
519    ///         run: cargo test   # runs at the same time as lint
520    /// ```
521    #[serde(default)]
522    pub parallel: bool,
523
524    /// Commands to run when this hook fires.
525    /// Each entry is either an inline command or a `$ref` to a definition.
526    #[serde(default)]
527    pub commands: Vec<CommandEntry>,
528}
529
530impl HookConfig {
531    /// Resolve all command entries to concrete Commands by expanding `$ref` entries
532    /// using the given definitions map. Unknown refs are silently skipped.
533    /// `$include` entries are skipped here; use [`resolved_commands_with_includes`] to resolve them.
534    pub fn resolved_commands<'a>(
535        &'a self,
536        definitions: &'a BTreeMap<String, DefinitionEntry>,
537    ) -> Vec<Command> {
538        let mut out = Vec::new();
539        for entry in &self.commands {
540            match entry {
541                CommandEntry::Inline(cmd) => out.push(cmd.clone()),
542                CommandEntry::Ref(r) => {
543                    if let Some(def) = definitions.get(&r.r#ref) {
544                        match def {
545                            DefinitionEntry::Single(cmd) => {
546                                let mut cmd = cmd.clone();
547                                if let Some(args) = &r.args {
548                                    cmd.run = format!("{} {}", cmd.run, args);
549                                }
550                                if let Some(name) = &r.name {
551                                    cmd.name = name.clone();
552                                }
553                                out.push(cmd);
554                            }
555                            DefinitionEntry::List(cmds) => out.extend(cmds.iter().cloned()),
556                        }
557                    }
558                }
559                CommandEntry::Include(_) => {} // resolved separately via resolved_commands_with_includes
560            }
561        }
562        out
563    }
564
565    /// Like [`resolved_commands`] but also resolves `$include` entries by reading
566    /// the referenced external files. Returns `Err` if an include cannot be resolved.
567    pub fn resolved_commands_with_includes(
568        &self,
569        definitions: &BTreeMap<String, DefinitionEntry>,
570        includes: &[IncludeEntry],
571    ) -> Result<Vec<Command>> {
572        let mut out = Vec::new();
573        for entry in &self.commands {
574            match entry {
575                CommandEntry::Inline(cmd) => out.push(cmd.clone()),
576                CommandEntry::Ref(r) => {
577                    if let Some(def) = definitions.get(&r.r#ref) {
578                        match def {
579                            DefinitionEntry::Single(cmd) => {
580                                let mut cmd = cmd.clone();
581                                if let Some(args) = &r.args {
582                                    cmd.run = format!("{} {}", cmd.run, args);
583                                }
584                                if let Some(name) = &r.name {
585                                    cmd.name = name.clone();
586                                }
587                                out.push(cmd);
588                            }
589                            DefinitionEntry::List(cmds) => out.extend(cmds.iter().cloned()),
590                        }
591                    }
592                }
593                CommandEntry::Include(inc_ref) => {
594                    out.push(resolve_include_entry(inc_ref, includes)?);
595                }
596            }
597        }
598        Ok(out)
599    }
600}
601
602fn default_true() -> bool {
603    true
604}
605
606/// A command entry in a hook's command list: either an inline command definition
607/// or a reference to a named definition (`$ref: name`).
608///
609/// The `$ref` form lets you reuse commands defined in the `definitions` section
610/// without YAML anchors, so changes round-trip correctly through the UI editor.
611///
612/// Example using `$ref`:
613/// ```yaml
614/// hooks:
615///   pre-commit:
616///     commands:
617///       - name: fmt
618///         run: cargo fmt --check
619///       - $ref: lint   # references definitions.lint
620/// ```
621#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
622#[serde(untagged)]
623pub enum CommandEntry {
624    /// A reference to a value in an included external file.
625    Include(IncludeRef),
626    /// A reference to a named definition. Serialises as `{$ref: name}`.
627    Ref(RefEntry),
628    /// An inline command definition.
629    Inline(Command),
630}
631
632impl From<Command> for CommandEntry {
633    fn from(cmd: Command) -> Self {
634        CommandEntry::Inline(cmd)
635    }
636}
637
638/// A reference to a named definition in the `definitions` section.
639///
640/// Supports two optional overrides that are applied at the point of use,
641/// without modifying the shared definition:
642///
643/// ```yaml
644/// hooks:
645///   pre-commit:
646///     commands:
647///       - $ref: lint                  # use definition as-is
648///       - $ref: lint
649///         args: "--fix"               # appends to the definition's run command
650///         name: lint-fix              # overrides the display label
651/// ```
652#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
653pub struct RefEntry {
654    /// Name of the definition to reference.
655    #[serde(rename = "$ref")]
656    pub r#ref: String,
657
658    /// Extra arguments appended to the definition's `run` command.
659    ///
660    /// The final command executed is `{definition.run} {args}`.
661    /// For example, if the definition runs `npm run lint`, setting
662    /// `args: "--fix"` produces `npm run lint --fix`.
663    #[serde(default, skip_serializing_if = "Option::is_none")]
664    pub args: Option<String>,
665
666    /// Override the display name shown in hook output for this specific use.
667    /// When omitted, the definition's own `name` is used.
668    #[serde(default, skip_serializing_if = "Option::is_none")]
669    pub name: Option<String>,
670}
671
672/// A reference to a value in an included external file, used as a hook command.
673///
674/// The value at `run` (dot-notation path) is extracted from the file and used as the
675/// shell command. Optional `args` are appended to that command. Optional `env` sets
676/// environment variables for the invocation.
677///
678/// Example:
679/// ```yaml
680/// hooks:
681///   pre-commit:
682///     commands:
683///       - $include: packagejson   # references include with ref: packagejson
684///         run: scripts.lint       # navigates to scripts → lint in the file
685///         args: "--fix"           # appended to the resolved command
686///         env:
687///           NODE_ENV: production
688///         name: lint              # optional display name (defaults to last segment of run)
689/// ```
690#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
691pub struct IncludeRef {
692    /// Name of the include to reference (must match an `IncludeEntry` with this `ref`).
693    #[serde(rename = "$include")]
694    pub include_ref: String,
695
696    /// Dot-notation path to the value in the file (e.g. `scripts.lint`).
697    pub run: String,
698
699    /// Extra arguments appended to the resolved command string.
700    #[serde(default, skip_serializing_if = "Option::is_none")]
701    pub args: Option<String>,
702
703    /// Environment variables set for this command invocation.
704    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
705    pub env: BTreeMap<String, String>,
706
707    /// Optional display name. Defaults to the last segment of `run`.
708    #[serde(default, skip_serializing_if = "Option::is_none")]
709    pub name: Option<String>,
710}
711
712#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
713pub struct Command {
714    /// Human-readable label shown in output
715    pub name: String,
716
717    /// Shell command to execute. Hook arguments are available as $1, $2, etc.
718    pub run: String,
719
720    /// Names of commands in this hook that must complete successfully before
721    /// this command starts. Forms a DAG; cycles are detected and rejected.
722    #[serde(default, skip_serializing_if = "Vec::is_empty")]
723    pub depends: Vec<String>,
724
725    /// Additional environment variables for this command
726    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
727    pub env: BTreeMap<String, String>,
728
729    /// Mark this command as a test-only command (informational; not run during normal hooks).
730    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
731    pub test: bool,
732
733    /// Cache configuration for this command.
734    /// Requires `cache.enabled: true` in the top-level config.
735    #[serde(default, skip_serializing_if = "Option::is_none")]
736    pub cache: Option<CommandCache>,
737}
738
739// ---------------------------------------------------------------------------
740// YAML pre-processor
741// ---------------------------------------------------------------------------
742
743fn flatten_command_aliases(root: &mut serde_yaml::Value) {
744    let root_map = match root.as_mapping_mut() {
745        Some(m) => m,
746        None => return,
747    };
748
749    let hooks_key = serde_yaml::Value::String("hooks".into());
750    let hooks = match root_map.get_mut(&hooks_key) {
751        Some(h) => h,
752        None => return,
753    };
754    let hooks_map = match hooks.as_mapping_mut() {
755        Some(m) => m,
756        None => return,
757    };
758
759    let hook_keys: Vec<serde_yaml::Value> = hooks_map.keys().cloned().collect();
760
761    for hk in hook_keys {
762        let hook_val = match hooks_map.get_mut(&hk) {
763            Some(v) => v,
764            None => continue,
765        };
766        let hook_map = match hook_val.as_mapping_mut() {
767            Some(m) => m,
768            None => continue,
769        };
770
771        let cmds_key = serde_yaml::Value::String("commands".into());
772        let cmds_val = match hook_map.get_mut(&cmds_key) {
773            Some(v) => v,
774            None => continue,
775        };
776        let seq = match cmds_val.as_sequence_mut() {
777            Some(s) => s,
778            None => continue,
779        };
780
781        let original: Vec<serde_yaml::Value> = seq.drain(..).collect();
782        for item in original {
783            match item {
784                serde_yaml::Value::Sequence(inner) => seq.extend(inner),
785                other => seq.push(other),
786            }
787        }
788    }
789}
790
791impl Config {
792    pub fn load(path: &Path) -> Result<Self> {
793        let content = std::fs::read_to_string(path)
794            .with_context(|| format!("Failed to read {}", path.display()))?;
795
796        let mut raw: serde_yaml::Value = serde_yaml::from_str(&content)
797            .with_context(|| format!("Failed to parse YAML in {}", path.display()))?;
798
799        flatten_command_aliases(&mut raw);
800
801        serde_yaml::from_value(raw)
802            .with_context(|| format!("Failed to deserialise {}", path.display()))
803    }
804
805    /// Find and load config from the current directory.
806    pub fn find() -> Result<(Self, PathBuf)> {
807        let path = Path::new(CONFIG_FILE);
808        if path.exists() {
809            return Ok((Self::load(path)?, path.to_path_buf()));
810        }
811        anyhow::bail!(
812            "No {} found in the current directory. Run `githops init` first.",
813            CONFIG_FILE
814        )
815    }
816
817    pub fn save(&self, path: &Path) -> Result<()> {
818        let yaml_body = serde_yaml::to_string(self)?;
819        let content = if path.exists() {
820            let existing = std::fs::read_to_string(path).unwrap_or_default();
821            let first = existing.lines().next().unwrap_or("");
822            if first.starts_with("# yaml-language-server:") {
823                format!("{}\n{}", first, yaml_body)
824            } else {
825                yaml_body
826            }
827        } else {
828            format!(
829                "# yaml-language-server: $schema={}\n{}",
830                SCHEMA_FILE, yaml_body
831            )
832        };
833        std::fs::write(path, content)?;
834        Ok(())
835    }
836}
837
838// ---------------------------------------------------------------------------
839// Include resolution
840// ---------------------------------------------------------------------------
841
842/// Resolve a `$include` command entry by reading the referenced external file
843/// and navigating the dot-notation path specified in `run`.
844///
845/// If `args` is set, it is appended to the resolved command string.
846/// Supports local files, remote HTTP/HTTPS files, and files from remote Git repositories.
847pub fn resolve_include_entry(inc_ref: &IncludeRef, includes: &[IncludeEntry]) -> Result<Command> {
848    let entry = includes
849        .iter()
850        .find(|e| e.ref_name() == inc_ref.include_ref)
851        .ok_or_else(|| anyhow::anyhow!(
852            "Include '{}' not defined in the `include:` section.",
853            inc_ref.include_ref
854        ))?;
855
856    let (content, file_type) = fetch_include_content(entry)?;
857
858    let base_run = match file_type {
859        IncludeType::Json => {
860            let json: serde_json::Value = serde_json::from_str(&content)
861                .with_context(|| format!("Failed to parse JSON from '{}'", entry.path()))?;
862            navigate_json(&json, &inc_ref.run)
863                .with_context(|| format!("Path '{}' not found in '{}'", inc_ref.run, entry.path()))?
864        }
865        IncludeType::Toml => {
866            let toml_val: toml::Value = toml::from_str(&content)
867                .with_context(|| format!("Failed to parse TOML from '{}'", entry.path()))?;
868            navigate_toml(&toml_val, &inc_ref.run)
869                .with_context(|| format!("Path '{}' not found in '{}'", inc_ref.run, entry.path()))?
870        }
871        IncludeType::Yaml => {
872            let yaml_val: serde_yaml::Value = serde_yaml::from_str(&content)
873                .with_context(|| format!("Failed to parse YAML from '{}'", entry.path()))?;
874            navigate_yaml(&yaml_val, &inc_ref.run)
875                .with_context(|| format!("Path '{}' not found in '{}'", inc_ref.run, entry.path()))?
876        }
877    };
878
879    let run = match &inc_ref.args {
880        Some(extra) if !extra.is_empty() => format!("{} {}", base_run, extra),
881        _ => base_run,
882    };
883
884    let name = inc_ref.name.clone().unwrap_or_else(|| {
885        inc_ref.run.split('.').last().unwrap_or(&inc_ref.run).to_string()
886    });
887
888    Ok(Command {
889        name,
890        run,
891        depends: vec![],
892        env: inc_ref.env.clone(),
893        test: false,
894        cache: None,
895    })
896}
897
898/// Fetch the content string and resolved file type for any include source.
899fn fetch_include_content(entry: &IncludeEntry) -> Result<(String, &IncludeType)> {
900    match entry {
901        IncludeEntry::Local(l) => {
902            let content = std::fs::read_to_string(&l.path)
903                .with_context(|| format!("Failed to read include file '{}'", l.path))?;
904            Ok((content, &l.file_type))
905        }
906        IncludeEntry::Remote(r) => {
907            let content = ureq::get(&r.url)
908                .call()
909                .with_context(|| format!("Failed to fetch remote include '{}'", r.url))?
910                .into_string()
911                .with_context(|| format!("Failed to read response body from '{}'", r.url))?;
912            Ok((content, &r.file_type))
913        }
914        IncludeEntry::Git(g) => {
915            let content = fetch_git_file(&g.url, &g.rev, &g.file)?;
916            Ok((content, &g.file_type))
917        }
918    }
919}
920
921/// Clone a git repository at the given revision and read a single file from it.
922/// Uses a temporary directory that is cleaned up after the file is read.
923fn fetch_git_file(url: &str, rev: &str, file: &str) -> Result<String> {
924    use std::process::Command as Cmd;
925
926    // Build a deterministic temp path from the url+rev hash to allow basic re-use
927    // within the same process run, but always re-clone for correctness.
928    let hash = {
929        use sha2::{Digest, Sha256};
930        let mut h = Sha256::new();
931        h.update(url.as_bytes());
932        h.update(rev.as_bytes());
933        format!("{:x}", h.finalize())[..12].to_string()
934    };
935    let tmp_dir = std::env::temp_dir().join(format!("githops-git-{}", hash));
936
937    // Re-use existing clone if present (avoids duplicate clones in the same run).
938    if !tmp_dir.exists() {
939        let status = Cmd::new("git")
940            .args([
941                "clone",
942                "--depth=1",
943                "--branch", rev,
944                "--",
945                url,
946                tmp_dir.to_str().unwrap_or("/tmp/githops-git"),
947            ])
948            .stdout(std::process::Stdio::null())
949            .stderr(std::process::Stdio::null())
950            .status()
951            .with_context(|| format!("Failed to run `git clone` for '{}'", url))?;
952
953        if !status.success() {
954            anyhow::bail!(
955                "git clone failed for '{}' at revision '{}'. \
956                 Make sure the URL is accessible and the revision exists.",
957                url, rev
958            );
959        }
960    }
961
962    let file_path = tmp_dir.join(file);
963    let content = std::fs::read_to_string(&file_path)
964        .with_context(|| format!("File '{}' not found in git repository '{}'", file, url))?;
965
966    Ok(content)
967}
968
969fn navigate_json(value: &serde_json::Value, path: &str) -> Result<String> {
970    let mut current = value;
971    for key in path.split('.') {
972        current = current
973            .get(key)
974            .ok_or_else(|| anyhow::anyhow!("Key '{}' not found", key))?;
975    }
976    match current {
977        serde_json::Value::String(s) => Ok(s.clone()),
978        other => Ok(other.to_string()),
979    }
980}
981
982fn navigate_toml(value: &toml::Value, path: &str) -> Result<String> {
983    let mut current = value;
984    for key in path.split('.') {
985        current = current
986            .get(key)
987            .ok_or_else(|| anyhow::anyhow!("Key '{}' not found", key))?;
988    }
989    match current {
990        toml::Value::String(s) => Ok(s.clone()),
991        other => Ok(other.to_string()),
992    }
993}
994
995fn navigate_yaml(value: &serde_yaml::Value, path: &str) -> Result<String> {
996    let mut current = value;
997    for key in path.split('.') {
998        current = current
999            .get(key)
1000            .ok_or_else(|| anyhow::anyhow!("Key '{}' not found", key))?;
1001    }
1002    match current {
1003        serde_yaml::Value::String(s) => Ok(s.clone()),
1004        serde_yaml::Value::Number(n) => Ok(n.to_string()),
1005        other => {
1006            serde_yaml::to_string(other)
1007                .map(|s| s.trim().to_string())
1008                .map_err(|e| anyhow::anyhow!("Cannot convert YAML value to string: {}", e))
1009        }
1010    }
1011}
1012
1013// ---------------------------------------------------------------------------
1014// Validation (shared with graphui)
1015// ---------------------------------------------------------------------------
1016
1017pub fn validate_depends_pub(commands: &[Command]) -> Result<()> {
1018    let names: std::collections::HashSet<&str> =
1019        commands.iter().map(|c| c.name.as_str()).collect();
1020
1021    for cmd in commands {
1022        for dep in &cmd.depends {
1023            if !names.contains(dep.as_str()) {
1024                anyhow::bail!(
1025                    "Command '{}' depends on '{}', which is not defined in this hook.",
1026                    cmd.name,
1027                    dep
1028                );
1029            }
1030            if dep == &cmd.name {
1031                anyhow::bail!("Command '{}' depends on itself.", cmd.name);
1032            }
1033        }
1034    }
1035    Ok(())
1036}
1037
1038#[cfg(test)]
1039mod tests {
1040    use super::*;
1041    use std::collections::BTreeMap;
1042
1043    // -----------------------------------------------------------------------
1044    // Config parsing tests
1045    // -----------------------------------------------------------------------
1046
1047    #[test]
1048    fn test_parse_minimal_config() {
1049        let yaml = r#"
1050hooks:
1051  pre-commit:
1052    enabled: true
1053    commands:
1054      - name: lint
1055        run: echo lint
1056"#;
1057        let config: Config = serde_yaml::from_str(yaml).unwrap();
1058        let hook = config.hooks.pre_commit.as_ref().unwrap();
1059        assert!(hook.enabled);
1060        assert_eq!(hook.commands.len(), 1);
1061    }
1062
1063    #[test]
1064    fn test_parse_config_with_definitions() {
1065        let yaml = r#"
1066definitions:
1067  lint:
1068    name: ESLint
1069    run: npx eslint .
1070
1071hooks:
1072  pre-commit:
1073    commands:
1074      - $ref: lint
1075"#;
1076        let config: Config = serde_yaml::from_str(yaml).unwrap();
1077        assert!(config.definitions.contains_key("lint"));
1078        let hook = config.hooks.pre_commit.as_ref().unwrap();
1079        assert_eq!(hook.commands.len(), 1);
1080        assert!(matches!(hook.commands[0], CommandEntry::Ref(_)));
1081    }
1082
1083    #[test]
1084    fn test_parse_config_with_local_include() {
1085        let yaml = "include:\n  - source: local\n    path: package.json\n    type: json\n    ref: pkg\nhooks:\n  pre-commit:\n    commands:\n      - $include: pkg\n        run: scripts.lint\n";
1086        let config: Config = serde_yaml::from_str(yaml).unwrap();
1087        assert_eq!(config.include.len(), 1);
1088        assert!(matches!(config.include[0], IncludeEntry::Local(_)));
1089        assert_eq!(config.include[0].ref_name(), "pkg");
1090        let hook = config.hooks.pre_commit.as_ref().unwrap();
1091        assert!(matches!(hook.commands[0], CommandEntry::Include(_)));
1092    }
1093
1094    #[test]
1095    fn test_parse_config_with_remote_include() {
1096        let yaml = "include:\n  - source: remote\n    url: 'https://example.com/scripts.yaml'\n    type: yaml\n    ref: remote1\n";
1097        let config: Config = serde_yaml::from_str(yaml).unwrap();
1098        assert_eq!(config.include.len(), 1);
1099        assert!(matches!(config.include[0], IncludeEntry::Remote(_)));
1100        assert_eq!(config.include[0].ref_name(), "remote1");
1101    }
1102
1103    #[test]
1104    fn test_parse_config_with_git_include() {
1105        let yaml = "include:\n  - source: git\n    url: 'https://github.com/org/repo.git'\n    rev: main\n    file: ci/scripts.yaml\n    ref: repo1\n";
1106        let config: Config = serde_yaml::from_str(yaml).unwrap();
1107        assert_eq!(config.include.len(), 1);
1108        assert!(matches!(config.include[0], IncludeEntry::Git(_)));
1109        assert_eq!(config.include[0].ref_name(), "repo1");
1110        if let IncludeEntry::Git(g) = &config.include[0] {
1111            assert_eq!(g.rev, "main");
1112            assert_eq!(g.file, "ci/scripts.yaml");
1113        }
1114    }
1115
1116    #[test]
1117    fn test_include_entry_ref_name_accessor() {
1118        let local = IncludeEntry::Local(LocalInclude {
1119            path: "pkg.json".into(),
1120            file_type: IncludeType::Json,
1121            ref_name: "mypkg".into(),
1122        });
1123        assert_eq!(local.ref_name(), "mypkg");
1124        assert_eq!(local.path(), "pkg.json");
1125        assert!(matches!(local.file_type(), IncludeType::Json));
1126    }
1127
1128    // -----------------------------------------------------------------------
1129    // CommandEntry deserialization tests
1130    // -----------------------------------------------------------------------
1131
1132    #[test]
1133    fn test_command_entry_inline_deser() {
1134        let yaml = r#"name: lint
1135run: npx eslint ."#;
1136        let entry: CommandEntry = serde_yaml::from_str(yaml).unwrap();
1137        assert!(matches!(entry, CommandEntry::Inline(_)));
1138        if let CommandEntry::Inline(cmd) = entry {
1139            assert_eq!(cmd.name, "lint");
1140            assert_eq!(cmd.run, "npx eslint .");
1141        }
1142    }
1143
1144    #[test]
1145    fn test_command_entry_ref_deser() {
1146        let yaml = r#"$ref: lint"#;
1147        let entry: CommandEntry = serde_yaml::from_str(yaml).unwrap();
1148        assert!(matches!(entry, CommandEntry::Ref(_)));
1149        if let CommandEntry::Ref(r) = entry {
1150            assert_eq!(r.r#ref, "lint");
1151            assert!(r.args.is_none());
1152        }
1153    }
1154
1155    #[test]
1156    fn test_command_entry_ref_with_args_deser() {
1157        let yaml = "$ref: lint\nargs: \"--fix\"";
1158        let entry: CommandEntry = serde_yaml::from_str(yaml).unwrap();
1159        if let CommandEntry::Ref(r) = entry {
1160            assert_eq!(r.args.as_deref(), Some("--fix"));
1161        } else {
1162            panic!("Expected Ref variant");
1163        }
1164    }
1165
1166    #[test]
1167    fn test_command_entry_include_deser() {
1168        let yaml = "$include: mypkg\nrun: scripts.lint";
1169        let entry: CommandEntry = serde_yaml::from_str(yaml).unwrap();
1170        assert!(matches!(entry, CommandEntry::Include(_)));
1171        if let CommandEntry::Include(inc) = entry {
1172            assert_eq!(inc.include_ref, "mypkg");
1173            assert_eq!(inc.run, "scripts.lint");
1174            assert!(inc.args.is_none());
1175            assert!(inc.name.is_none());
1176        }
1177    }
1178
1179    #[test]
1180    fn test_command_entry_include_with_args_deser() {
1181        let yaml = "$include: mypkg\nrun: scripts.lint\nargs: \"--fix\"";
1182        let entry: CommandEntry = serde_yaml::from_str(yaml).unwrap();
1183        if let CommandEntry::Include(inc) = entry {
1184            assert_eq!(inc.run, "scripts.lint");
1185            assert_eq!(inc.args.as_deref(), Some("--fix"));
1186        } else {
1187            panic!("Expected Include variant");
1188        }
1189    }
1190
1191    #[test]
1192    fn test_command_entry_include_with_name_deser() {
1193        let yaml = "$include: mypkg\nrun: scripts.lint\nname: ESLint";
1194        let entry: CommandEntry = serde_yaml::from_str(yaml).unwrap();
1195        if let CommandEntry::Include(inc) = entry {
1196            assert_eq!(inc.name.as_deref(), Some("ESLint"));
1197        } else {
1198            panic!("Expected Include variant");
1199        }
1200    }
1201
1202    // -----------------------------------------------------------------------
1203    // resolved_commands tests
1204    // -----------------------------------------------------------------------
1205
1206    #[test]
1207    fn test_resolved_commands_inline_only() {
1208        let hook = HookConfig {
1209            enabled: true,
1210            parallel: false,
1211            commands: vec![
1212                CommandEntry::Inline(Command {
1213                    name: "lint".into(),
1214                    run: "echo lint".into(),
1215                    depends: vec![],
1216                    env: BTreeMap::new(),
1217                    test: false,
1218                    cache: None,
1219                }),
1220            ],
1221        };
1222        let resolved = hook.resolved_commands(&BTreeMap::new());
1223        assert_eq!(resolved.len(), 1);
1224        assert_eq!(resolved[0].name, "lint");
1225    }
1226
1227    #[test]
1228    fn test_resolved_commands_ref_expansion() {
1229        let mut defs = BTreeMap::new();
1230        defs.insert("lint".to_string(), DefinitionEntry::Single(Command {
1231            name: "lint".into(),
1232            run: "npx eslint .".into(),
1233            depends: vec![],
1234            env: BTreeMap::new(),
1235            test: false,
1236            cache: None,
1237        }));
1238        let hook = HookConfig {
1239            enabled: true,
1240            parallel: false,
1241            commands: vec![
1242                CommandEntry::Ref(RefEntry { r#ref: "lint".into(), args: None, name: None }),
1243            ],
1244        };
1245        let resolved = hook.resolved_commands(&defs);
1246        assert_eq!(resolved.len(), 1);
1247        assert_eq!(resolved[0].run, "npx eslint .");
1248    }
1249
1250    #[test]
1251    fn test_resolved_commands_ref_with_args() {
1252        let mut defs = BTreeMap::new();
1253        defs.insert("lint".to_string(), DefinitionEntry::Single(Command {
1254            name: "lint".into(),
1255            run: "npx eslint .".into(),
1256            depends: vec![],
1257            env: BTreeMap::new(),
1258            test: false,
1259            cache: None,
1260        }));
1261        let hook = HookConfig {
1262            enabled: true,
1263            parallel: false,
1264            commands: vec![
1265                CommandEntry::Ref(RefEntry {
1266                    r#ref: "lint".into(),
1267                    args: Some("--fix".into()),
1268                    name: None,
1269                }),
1270            ],
1271        };
1272        let resolved = hook.resolved_commands(&defs);
1273        assert_eq!(resolved[0].run, "npx eslint . --fix");
1274    }
1275
1276    #[test]
1277    fn test_resolved_commands_ref_with_name_override() {
1278        let mut defs = BTreeMap::new();
1279        defs.insert("lint".to_string(), DefinitionEntry::Single(Command {
1280            name: "lint".into(),
1281            run: "npx eslint .".into(),
1282            depends: vec![],
1283            env: BTreeMap::new(),
1284            test: false,
1285            cache: None,
1286        }));
1287        let hook = HookConfig {
1288            enabled: true,
1289            parallel: false,
1290            commands: vec![
1291                CommandEntry::Ref(RefEntry {
1292                    r#ref: "lint".into(),
1293                    args: None,
1294                    name: Some("ESLint (fix)".into()),
1295                }),
1296            ],
1297        };
1298        let resolved = hook.resolved_commands(&defs);
1299        assert_eq!(resolved[0].name, "ESLint (fix)");
1300    }
1301
1302    #[test]
1303    fn test_resolved_commands_list_definition() {
1304        let mut defs = BTreeMap::new();
1305        defs.insert("quality".to_string(), DefinitionEntry::List(vec![
1306            Command {
1307                name: "lint".into(),
1308                run: "echo lint".into(),
1309                depends: vec![],
1310                env: BTreeMap::new(),
1311                test: false,
1312                cache: None,
1313            },
1314            Command {
1315                name: "test".into(),
1316                run: "echo test".into(),
1317                depends: vec![],
1318                env: BTreeMap::new(),
1319                test: false,
1320                cache: None,
1321            },
1322        ]));
1323        let hook = HookConfig {
1324            enabled: true,
1325            parallel: false,
1326            commands: vec![
1327                CommandEntry::Ref(RefEntry { r#ref: "quality".into(), args: None, name: None }),
1328            ],
1329        };
1330        let resolved = hook.resolved_commands(&defs);
1331        assert_eq!(resolved.len(), 2);
1332    }
1333
1334    #[test]
1335    fn test_resolved_commands_skips_include_entries() {
1336        let hook = HookConfig {
1337            enabled: true,
1338            parallel: false,
1339            commands: vec![
1340                CommandEntry::Include(IncludeRef {
1341                    include_ref: "pkg".into(),
1342                    run: "scripts.lint".into(),
1343                    args: None,
1344                    env: BTreeMap::new(),
1345                    name: None,
1346                }),
1347                CommandEntry::Inline(Command {
1348                    name: "fmt".into(),
1349                    run: "echo fmt".into(),
1350                    depends: vec![],
1351                    env: BTreeMap::new(),
1352                    test: false,
1353                    cache: None,
1354                }),
1355            ],
1356        };
1357        let resolved = hook.resolved_commands(&BTreeMap::new());
1358        assert_eq!(resolved.len(), 1);
1359        assert_eq!(resolved[0].name, "fmt");
1360    }
1361
1362    #[test]
1363    fn test_resolved_commands_with_includes_local() {
1364        use std::io::Write;
1365        use tempfile::NamedTempFile;
1366
1367        let mut f = NamedTempFile::new().unwrap();
1368        writeln!(f, r#"{{"scripts": {{"lint": "eslint . --ext .ts"}}}}"#).unwrap();
1369        let path = f.path().to_str().unwrap().to_string();
1370
1371        let includes = vec![IncludeEntry::Local(LocalInclude {
1372            path: path.clone(),
1373            file_type: IncludeType::Json,
1374            ref_name: "pkg".into(),
1375        })];
1376
1377        let hook = HookConfig {
1378            enabled: true,
1379            parallel: false,
1380            commands: vec![
1381                CommandEntry::Include(IncludeRef {
1382                    include_ref: "pkg".into(),
1383                    run: "scripts.lint".into(),
1384                    args: None,
1385                    env: BTreeMap::new(),
1386                    name: None,
1387                }),
1388            ],
1389        };
1390
1391        let resolved = hook.resolved_commands_with_includes(&BTreeMap::new(), &includes).unwrap();
1392        assert_eq!(resolved.len(), 1);
1393        assert_eq!(resolved[0].name, "lint");
1394        assert_eq!(resolved[0].run, "eslint . --ext .ts");
1395    }
1396
1397    #[test]
1398    fn test_resolved_commands_with_includes_name_override() {
1399        use std::io::Write;
1400        use tempfile::NamedTempFile;
1401
1402        let mut f = NamedTempFile::new().unwrap();
1403        writeln!(f, r#"{{"scripts": {{"lint": "eslint ."}}}}"#).unwrap();
1404        let path = f.path().to_str().unwrap().to_string();
1405
1406        let includes = vec![IncludeEntry::Local(LocalInclude {
1407            path,
1408            file_type: IncludeType::Json,
1409            ref_name: "pkg".into(),
1410        })];
1411
1412        let hook = HookConfig {
1413            enabled: true,
1414            parallel: false,
1415            commands: vec![
1416                CommandEntry::Include(IncludeRef {
1417                    include_ref: "pkg".into(),
1418                    run: "scripts.lint".into(),
1419                    args: None,
1420                    env: BTreeMap::new(),
1421                    name: Some("ESLint".into()),
1422                }),
1423            ],
1424        };
1425
1426        let resolved = hook.resolved_commands_with_includes(&BTreeMap::new(), &includes).unwrap();
1427        assert_eq!(resolved[0].name, "ESLint");
1428    }
1429
1430    #[test]
1431    fn test_resolved_commands_with_includes_unknown_ref_errors() {
1432        let includes: Vec<IncludeEntry> = vec![];
1433        let hook = HookConfig {
1434            enabled: true,
1435            parallel: false,
1436            commands: vec![
1437                CommandEntry::Include(IncludeRef {
1438                    include_ref: "nonexistent".into(),
1439                    run: "scripts.lint".into(),
1440                    args: None,
1441                    env: BTreeMap::new(),
1442                    name: None,
1443                }),
1444            ],
1445        };
1446        let result = hook.resolved_commands_with_includes(&BTreeMap::new(), &includes);
1447        assert!(result.is_err());
1448        let msg = result.unwrap_err().to_string();
1449        assert!(msg.contains("nonexistent"));
1450    }
1451
1452    // -----------------------------------------------------------------------
1453    // Navigation function tests
1454    // -----------------------------------------------------------------------
1455
1456    #[test]
1457    fn test_navigate_json_simple() {
1458        let json: serde_json::Value =
1459            serde_json::from_str(r#"{"scripts": {"lint": "eslint ."}}"#).unwrap();
1460        let result = navigate_json(&json, "scripts.lint").unwrap();
1461        assert_eq!(result, "eslint .");
1462    }
1463
1464    #[test]
1465    fn test_navigate_json_top_level() {
1466        let json: serde_json::Value = serde_json::from_str(r#"{"name": "myapp"}"#).unwrap();
1467        assert_eq!(navigate_json(&json, "name").unwrap(), "myapp");
1468    }
1469
1470    #[test]
1471    fn test_navigate_json_missing_key() {
1472        let json: serde_json::Value = serde_json::from_str(r#"{"scripts": {}}"#).unwrap();
1473        assert!(navigate_json(&json, "scripts.lint").is_err());
1474    }
1475
1476    #[test]
1477    fn test_navigate_json_deeply_nested() {
1478        let json: serde_json::Value =
1479            serde_json::from_str(r#"{"a": {"b": {"c": "deep"}}}"#).unwrap();
1480        assert_eq!(navigate_json(&json, "a.b.c").unwrap(), "deep");
1481    }
1482
1483    #[test]
1484    fn test_navigate_toml_simple() {
1485        let toml_val: toml::Value =
1486            toml::from_str("[scripts]\nlint = \"cargo clippy\"").unwrap();
1487        assert_eq!(navigate_toml(&toml_val, "scripts.lint").unwrap(), "cargo clippy");
1488    }
1489
1490    #[test]
1491    fn test_navigate_toml_missing_key() {
1492        let toml_val: toml::Value = toml::from_str("[scripts]\n").unwrap();
1493        assert!(navigate_toml(&toml_val, "scripts.missing").is_err());
1494    }
1495
1496    #[test]
1497    fn test_navigate_yaml_simple() {
1498        let yaml_val: serde_yaml::Value =
1499            serde_yaml::from_str("scripts:\n  lint: \"npm run lint\"").unwrap();
1500        assert_eq!(navigate_yaml(&yaml_val, "scripts.lint").unwrap(), "npm run lint");
1501    }
1502
1503    #[test]
1504    fn test_navigate_yaml_missing_key() {
1505        let yaml_val: serde_yaml::Value = serde_yaml::from_str("scripts: {}").unwrap();
1506        assert!(navigate_yaml(&yaml_val, "scripts.missing").is_err());
1507    }
1508
1509    // -----------------------------------------------------------------------
1510    // resolve_include_entry tests
1511    // -----------------------------------------------------------------------
1512
1513    #[test]
1514    fn test_resolve_include_local_json() {
1515        use std::io::Write;
1516        use tempfile::NamedTempFile;
1517
1518        let mut f = NamedTempFile::new().unwrap();
1519        write!(f, r#"{{"scripts": {{"test": "jest"}}}}"#).unwrap();
1520
1521        let includes = vec![IncludeEntry::Local(LocalInclude {
1522            path: f.path().to_str().unwrap().to_string(),
1523            file_type: IncludeType::Json,
1524            ref_name: "pkg".into(),
1525        })];
1526        let inc_ref = IncludeRef {
1527            include_ref: "pkg".into(),
1528            run: "scripts.test".into(),
1529            args: None,
1530            env: BTreeMap::new(),
1531            name: None,
1532        };
1533        let cmd = resolve_include_entry(&inc_ref, &includes).unwrap();
1534        assert_eq!(cmd.run, "jest");
1535        assert_eq!(cmd.name, "test");
1536    }
1537
1538    #[test]
1539    fn test_resolve_include_local_toml() {
1540        use std::io::Write;
1541        use tempfile::NamedTempFile;
1542
1543        let mut f = NamedTempFile::new().unwrap();
1544        write!(f, "[scripts]\nbuild = \"cargo build --release\"").unwrap();
1545
1546        let includes = vec![IncludeEntry::Local(LocalInclude {
1547            path: f.path().to_str().unwrap().to_string(),
1548            file_type: IncludeType::Toml,
1549            ref_name: "cargo".into(),
1550        })];
1551        let inc_ref = IncludeRef {
1552            include_ref: "cargo".into(),
1553            run: "scripts.build".into(),
1554            args: None,
1555            env: BTreeMap::new(),
1556            name: None,
1557        };
1558        let cmd = resolve_include_entry(&inc_ref, &includes).unwrap();
1559        assert_eq!(cmd.run, "cargo build --release");
1560    }
1561
1562    #[test]
1563    fn test_resolve_include_local_yaml() {
1564        use std::io::Write;
1565        use tempfile::NamedTempFile;
1566
1567        let mut f = NamedTempFile::new().unwrap();
1568        write!(f, "scripts:\n  lint: \"eslint .\"\n").unwrap();
1569
1570        let includes = vec![IncludeEntry::Local(LocalInclude {
1571            path: f.path().to_str().unwrap().to_string(),
1572            file_type: IncludeType::Yaml,
1573            ref_name: "scripts".into(),
1574        })];
1575        let inc_ref = IncludeRef {
1576            include_ref: "scripts".into(),
1577            run: "scripts.lint".into(),
1578            args: None,
1579            env: BTreeMap::new(),
1580            name: None,
1581        };
1582        let cmd = resolve_include_entry(&inc_ref, &includes).unwrap();
1583        assert_eq!(cmd.run, "eslint .");
1584    }
1585
1586    #[test]
1587    fn test_resolve_include_missing_ref() {
1588        let includes: Vec<IncludeEntry> = vec![];
1589        let inc_ref = IncludeRef {
1590            include_ref: "pkg".into(),
1591            run: "scripts.lint".into(),
1592            args: None,
1593            env: BTreeMap::new(),
1594            name: None,
1595        };
1596        let result = resolve_include_entry(&inc_ref, &includes);
1597        assert!(result.is_err());
1598        assert!(result.unwrap_err().to_string().contains("pkg"));
1599    }
1600
1601    #[test]
1602    fn test_resolve_include_missing_file() {
1603        let includes = vec![IncludeEntry::Local(LocalInclude {
1604            path: "/nonexistent/path/file.json".into(),
1605            file_type: IncludeType::Json,
1606            ref_name: "pkg".into(),
1607        })];
1608        let inc_ref = IncludeRef {
1609            include_ref: "pkg".into(),
1610            run: "scripts.lint".into(),
1611            args: None,
1612            env: BTreeMap::new(),
1613            name: None,
1614        };
1615        assert!(resolve_include_entry(&inc_ref, &includes).is_err());
1616    }
1617
1618    #[test]
1619    fn test_resolve_include_name_defaults_to_last_segment() {
1620        use std::io::Write;
1621        use tempfile::NamedTempFile;
1622
1623        let mut f = NamedTempFile::new().unwrap();
1624        write!(f, r#"{{"scripts": {{"mytest": "jest --coverage"}}}}"#).unwrap();
1625
1626        let includes = vec![IncludeEntry::Local(LocalInclude {
1627            path: f.path().to_str().unwrap().to_string(),
1628            file_type: IncludeType::Json,
1629            ref_name: "pkg".into(),
1630        })];
1631        let inc_ref = IncludeRef {
1632            include_ref: "pkg".into(),
1633            run: "scripts.mytest".into(),
1634            args: None,
1635            env: BTreeMap::new(),
1636            name: None,
1637        };
1638        let cmd = resolve_include_entry(&inc_ref, &includes).unwrap();
1639        assert_eq!(cmd.name, "mytest");
1640    }
1641
1642    // -----------------------------------------------------------------------
1643    // validate_depends tests
1644    // -----------------------------------------------------------------------
1645
1646    #[test]
1647    fn test_validate_depends_valid() {
1648        let cmds = vec![
1649            Command {
1650                name: "a".into(),
1651                run: "echo a".into(),
1652                depends: vec![],
1653                env: BTreeMap::new(),
1654                test: false,
1655                cache: None,
1656            },
1657            Command {
1658                name: "b".into(),
1659                run: "echo b".into(),
1660                depends: vec!["a".into()],
1661                env: BTreeMap::new(),
1662                test: false,
1663                cache: None,
1664            },
1665        ];
1666        assert!(validate_depends_pub(&cmds).is_ok());
1667    }
1668
1669    #[test]
1670    fn test_validate_depends_unknown_dep() {
1671        let cmds = vec![Command {
1672            name: "b".into(),
1673            run: "echo b".into(),
1674            depends: vec!["nonexistent".into()],
1675            env: BTreeMap::new(),
1676            test: false,
1677            cache: None,
1678        }];
1679        let result = validate_depends_pub(&cmds);
1680        assert!(result.is_err());
1681        assert!(result.unwrap_err().to_string().contains("nonexistent"));
1682    }
1683
1684    #[test]
1685    fn test_validate_depends_self_reference() {
1686        let cmds = vec![Command {
1687            name: "a".into(),
1688            run: "echo a".into(),
1689            depends: vec!["a".into()],
1690            env: BTreeMap::new(),
1691            test: false,
1692            cache: None,
1693        }];
1694        let result = validate_depends_pub(&cmds);
1695        assert!(result.is_err());
1696        assert!(result.unwrap_err().to_string().contains("itself"));
1697    }
1698
1699    #[test]
1700    fn test_validate_depends_empty() {
1701        assert!(validate_depends_pub(&[]).is_ok());
1702    }
1703
1704    // -----------------------------------------------------------------------
1705    // Config serialization round-trip tests
1706    // -----------------------------------------------------------------------
1707
1708    #[test]
1709    fn test_config_roundtrip_with_include() {
1710        let mut config = Config::default();
1711        config.include.push(IncludeEntry::Local(LocalInclude {
1712            path: "package.json".into(),
1713            file_type: IncludeType::Json,
1714            ref_name: "pkg".into(),
1715        }));
1716        let yaml = serde_yaml::to_string(&config).unwrap();
1717        assert!(yaml.contains("source: local"), "serialized yaml: {}", yaml);
1718        let parsed: Config = serde_yaml::from_str(&yaml).unwrap();
1719        assert_eq!(parsed.include.len(), 1);
1720        assert_eq!(parsed.include[0].ref_name(), "pkg");
1721    }
1722
1723    #[test]
1724    fn test_remote_include_type_defaults_to_yaml() {
1725        let yaml = "include:\n  - source: remote\n    url: 'https://example.com/file.yaml'\n    ref: myfile\n";
1726        let config: Config = serde_yaml::from_str(yaml).unwrap();
1727        if let IncludeEntry::Remote(r) = &config.include[0] {
1728            assert!(matches!(r.file_type, IncludeType::Yaml));
1729        } else {
1730            panic!("Expected Remote variant");
1731        }
1732    }
1733}