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
10pub const SCHEMA_JSON: &str = include_str!("../githops.schema.json");
13
14pub 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
32#[serde(rename_all = "lowercase")]
33pub enum IncludeType {
34 Json,
36 Toml,
38 Yaml,
40}
41
42#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
44pub struct LocalInclude {
45 pub path: String,
47 #[serde(rename = "type")]
49 pub file_type: IncludeType,
50 #[serde(rename = "ref")]
52 pub ref_name: String,
53}
54
55#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
57pub struct RemoteInclude {
58 pub url: String,
60 #[serde(rename = "type", default = "default_yaml_type")]
62 pub file_type: IncludeType,
63 #[serde(rename = "ref")]
65 pub ref_name: String,
66}
67
68fn default_yaml_type() -> IncludeType {
69 IncludeType::Yaml
70}
71
72#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
74pub struct GitInclude {
75 pub url: String,
77 pub rev: String,
79 pub file: String,
81 #[serde(rename = "type", default = "default_yaml_type")]
83 pub file_type: IncludeType,
84 #[serde(rename = "ref")]
86 pub ref_name: String,
87}
88
89#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
109#[serde(tag = "source", rename_all = "lowercase")]
110pub enum IncludeEntry {
111 Local(LocalInclude),
113 Remote(RemoteInclude),
115 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 #[serde(default = "default_version")]
148 pub version: String,
149
150 #[serde(default, skip_serializing_if = "Vec::is_empty")]
170 pub include: Vec<IncludeEntry>,
171
172 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
207 pub definitions: BTreeMap<String, DefinitionEntry>,
208
209 #[serde(default)]
211 pub hooks: Hooks,
212
213 #[serde(default, skip_serializing_if = "GlobalCache::is_unconfigured")]
240 pub cache: GlobalCache,
241}
242
243#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema)]
245#[serde(rename_all = "kebab-case")]
246pub struct GlobalCache {
247 #[serde(default)]
249 pub enabled: bool,
250
251 #[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#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
270#[serde(rename_all = "kebab-case")]
271pub struct CommandCache {
272 #[serde(default)]
276 pub inputs: Vec<String>,
277
278 #[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#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
290#[serde(untagged)]
291pub enum DefinitionEntry {
292 List(Vec<Command>),
294 Single(Command),
296}
297
298#[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 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 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 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 #[serde(default = "default_true")]
498 pub enabled: bool,
499
500 #[serde(default)]
522 pub parallel: bool,
523
524 #[serde(default)]
527 pub commands: Vec<CommandEntry>,
528}
529
530impl HookConfig {
531 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(_) => {} }
561 }
562 out
563 }
564
565 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#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
622#[serde(untagged)]
623pub enum CommandEntry {
624 Include(IncludeRef),
626 Ref(RefEntry),
628 Inline(Command),
630}
631
632impl From<Command> for CommandEntry {
633 fn from(cmd: Command) -> Self {
634 CommandEntry::Inline(cmd)
635 }
636}
637
638#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
653pub struct RefEntry {
654 #[serde(rename = "$ref")]
656 pub r#ref: String,
657
658 #[serde(default, skip_serializing_if = "Option::is_none")]
664 pub args: Option<String>,
665
666 #[serde(default, skip_serializing_if = "Option::is_none")]
669 pub name: Option<String>,
670}
671
672#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
691pub struct IncludeRef {
692 #[serde(rename = "$include")]
694 pub include_ref: String,
695
696 pub run: String,
698
699 #[serde(default, skip_serializing_if = "Option::is_none")]
701 pub args: Option<String>,
702
703 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
705 pub env: BTreeMap<String, String>,
706
707 #[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 pub name: String,
716
717 pub run: String,
719
720 #[serde(default, skip_serializing_if = "Vec::is_empty")]
723 pub depends: Vec<String>,
724
725 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
727 pub env: BTreeMap<String, String>,
728
729 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
731 pub test: bool,
732
733 #[serde(default, skip_serializing_if = "Option::is_none")]
736 pub cache: Option<CommandCache>,
737}
738
739fn 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 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
838pub 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
898fn 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
921fn fetch_git_file(url: &str, rev: &str, file: &str) -> Result<String> {
924 use std::process::Command as Cmd;
925
926 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 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
1013pub 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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}