Skip to main content

treeship_core/
rules.rs

1use serde::{Deserialize, Serialize};
2
3// ---------------------------------------------------------------------------
4// Config structs -- deserialized from .treeship/config.yaml
5// ---------------------------------------------------------------------------
6
7#[derive(Debug, Clone, Deserialize, Serialize)]
8pub struct ProjectConfig {
9    pub treeship: TreeshipMeta,
10    pub session: SessionConfig,
11    pub attest: AttestConfig,
12    #[serde(default)]
13    pub approvals: Option<ApprovalConfig>,
14    #[serde(default)]
15    pub hub: Option<HubConfig>,
16}
17
18#[derive(Debug, Clone, Deserialize, Serialize)]
19pub struct TreeshipMeta {
20    pub version: u32,
21}
22
23#[derive(Debug, Clone, Deserialize, Serialize)]
24pub struct SessionConfig {
25    pub actor: String,
26    #[serde(default)]
27    pub auto_start: bool,
28    #[serde(default)]
29    pub auto_checkpoint: bool,
30    #[serde(default)]
31    pub auto_push: bool,
32}
33
34#[derive(Debug, Clone, Deserialize, Serialize)]
35pub struct AttestConfig {
36    #[serde(default)]
37    pub commands: Vec<CommandRule>,
38    #[serde(default)]
39    pub paths: Vec<PathRule>,
40}
41
42#[derive(Debug, Clone, Deserialize, Serialize)]
43pub struct CommandRule {
44    pub pattern: String,
45    pub label: String,
46    #[serde(default)]
47    pub require_approval: bool,
48}
49
50#[derive(Debug, Clone, Deserialize, Serialize)]
51pub struct PathRule {
52    pub path: String,
53    pub on: String,
54    #[serde(default)]
55    pub label: Option<String>,
56    #[serde(default)]
57    pub alert: bool,
58}
59
60#[derive(Debug, Clone, Deserialize, Serialize)]
61pub struct ApprovalConfig {
62    #[serde(default)]
63    pub require_for: Vec<LabelRef>,
64    #[serde(default)]
65    pub auto_approve: Vec<LabelRef>,
66    #[serde(default)]
67    pub timeout: Option<String>,
68}
69
70#[derive(Debug, Clone, Deserialize, Serialize)]
71pub struct LabelRef {
72    pub label: String,
73}
74
75#[derive(Debug, Clone, Deserialize, Serialize)]
76pub struct HubConfig {
77    #[serde(default)]
78    pub endpoint: Option<String>,
79    #[serde(default)]
80    pub auto_push: bool,
81    #[serde(default)]
82    pub push_on: Vec<String>,
83}
84
85// ---------------------------------------------------------------------------
86// Match result
87// ---------------------------------------------------------------------------
88
89#[derive(Debug, Clone, PartialEq, Eq)]
90pub struct MatchResult {
91    pub should_attest: bool,
92    pub label: String,
93    pub require_approval: bool,
94}
95
96// ---------------------------------------------------------------------------
97// Path match result
98// ---------------------------------------------------------------------------
99
100#[derive(Debug, Clone, PartialEq, Eq)]
101pub struct PathMatchResult {
102    pub label: String,
103    pub alert: bool,
104    pub on: String,
105}
106
107// ---------------------------------------------------------------------------
108// Simple wildcard matching
109// ---------------------------------------------------------------------------
110
111/// Match a value against a simple wildcard pattern.
112///
113/// Supports three forms:
114///   "prefix*"  -- value must start with prefix
115///   "*suffix"  -- value must end with suffix
116///   "exact"    -- value must equal the pattern exactly
117fn wildcard_match(pattern: &str, value: &str) -> bool {
118    if pattern.ends_with('*') && !pattern.starts_with('*') {
119        // prefix match
120        let prefix = &pattern[..pattern.len() - 1];
121        value.starts_with(prefix)
122    } else if pattern.starts_with('*') && !pattern.ends_with('*') {
123        // suffix match
124        let suffix = &pattern[1..];
125        value.ends_with(suffix)
126    } else if pattern.starts_with('*') && pattern.ends_with('*') {
127        // contains match (both ends have wildcard)
128        let inner = &pattern[1..pattern.len() - 1];
129        value.contains(inner)
130    } else {
131        // exact match
132        pattern == value
133    }
134}
135
136/// Match a file path against a path pattern.
137///
138/// Supports:
139///   "src/**"       -- matches anything under src/
140///   "*lock*"       -- matches any path containing "lock"
141///   "*.env*"       -- matches any path containing ".env"
142///   "Cargo.toml"   -- exact match
143fn path_matches(pattern: &str, path: &str) -> bool {
144    // Handle directory glob: "src/**" matches "src/foo.rs", "src/bar/baz.ts"
145    if let Some(prefix) = pattern.strip_suffix("/**") {
146        return path.starts_with(prefix);
147    }
148    // Fall through to general wildcard matching
149    wildcard_match(pattern, path)
150}
151
152// ---------------------------------------------------------------------------
153// ProjectConfig implementation
154// ---------------------------------------------------------------------------
155
156impl ProjectConfig {
157    /// Load from a YAML file path.
158    pub fn load(path: &std::path::Path) -> Result<Self, String> {
159        let contents = std::fs::read_to_string(path)
160            .map_err(|e| format!("failed to read config file {}: {}", path.display(), e))?;
161        Self::from_yaml(&contents)
162    }
163
164    /// Parse from a YAML string (useful for tests and embedding).
165    pub fn from_yaml(yaml: &str) -> Result<Self, String> {
166        serde_yaml::from_str(yaml).map_err(|e| format!("failed to parse YAML config: {}", e))
167    }
168
169    /// Generate a sensible default config for a given project type.
170    ///
171    /// Supported project types: "node", "rust", "python", "general".
172    pub fn default_for(project_type: &str, actor: &str) -> Self {
173        let test_commands: Vec<CommandRule> = match project_type {
174            "node" => vec![
175                CommandRule {
176                    pattern: "npm test*".into(),
177                    label: "test suite".into(),
178                    require_approval: false,
179                },
180                CommandRule {
181                    pattern: "npx jest*".into(),
182                    label: "test suite".into(),
183                    require_approval: false,
184                },
185            ],
186            "rust" => vec![
187                CommandRule {
188                    pattern: "cargo test*".into(),
189                    label: "test suite".into(),
190                    require_approval: false,
191                },
192                CommandRule {
193                    pattern: "cargo clippy*".into(),
194                    label: "lint".into(),
195                    require_approval: false,
196                },
197            ],
198            "python" => vec![
199                CommandRule {
200                    pattern: "pytest*".into(),
201                    label: "test suite".into(),
202                    require_approval: false,
203                },
204                CommandRule {
205                    pattern: "python -m pytest*".into(),
206                    label: "test suite".into(),
207                    require_approval: false,
208                },
209            ],
210            _ => vec![],
211        };
212
213        let mut commands = test_commands;
214        // Common commands for every project type
215        commands.extend(vec![
216            CommandRule {
217                pattern: "git commit*".into(),
218                label: "code commit".into(),
219                require_approval: false,
220            },
221            CommandRule {
222                pattern: "git push*".into(),
223                label: "code push".into(),
224                require_approval: false,
225            },
226            CommandRule {
227                pattern: "kubectl apply*".into(),
228                label: "deployment".into(),
229                require_approval: true,
230            },
231            CommandRule {
232                pattern: "fly deploy*".into(),
233                label: "deployment".into(),
234                require_approval: true,
235            },
236        ]);
237
238        let paths = vec![
239            PathRule {
240                path: "src/**".into(),
241                on: "write".into(),
242                label: None,
243                alert: false,
244            },
245            PathRule {
246                path: "*lock*".into(),
247                on: "change".into(),
248                label: Some("dependency change".into()),
249                alert: false,
250            },
251            PathRule {
252                path: "*.env*".into(),
253                on: "access".into(),
254                label: Some("env file access".into()),
255                alert: true,
256            },
257        ];
258
259        let approvals = ApprovalConfig {
260            require_for: vec![LabelRef {
261                label: "deployment".into(),
262            }],
263            auto_approve: vec![
264                LabelRef {
265                    label: "test suite".into(),
266                },
267                LabelRef {
268                    label: "code commit".into(),
269                },
270            ],
271            timeout: Some("5m".into()),
272        };
273
274        ProjectConfig {
275            treeship: TreeshipMeta { version: 1 },
276            session: SessionConfig {
277                actor: actor.to_string(),
278                auto_start: true,
279                auto_checkpoint: true,
280                auto_push: false,
281            },
282            attest: AttestConfig { commands, paths },
283            approvals: Some(approvals),
284            hub: None,
285        }
286    }
287
288    /// Match a file path against the configured path rules.
289    ///
290    /// Returns `Some(PathMatchResult)` when the path matches a rule,
291    /// `None` when no rule matches.
292    pub fn match_path(&self, path: &str) -> Option<PathMatchResult> {
293        for rule in &self.attest.paths {
294            if path_matches(&rule.path, path) {
295                return Some(PathMatchResult {
296                    label: rule
297                        .label
298                        .clone()
299                        .unwrap_or_else(|| "file change".to_string()),
300                    alert: rule.alert,
301                    on: rule.on.clone(),
302                });
303            }
304        }
305        None
306    }
307
308    /// Match a command string against the configured rules.
309    ///
310    /// Returns `Some(MatchResult)` when the command matches a rule,
311    /// `None` when no rule matches.
312    pub fn match_command(&self, command: &str) -> Option<MatchResult> {
313        for rule in &self.attest.commands {
314            if wildcard_match(&rule.pattern, command) {
315                let mut require_approval = rule.require_approval;
316
317                // Check approval overrides
318                if let Some(ref approvals) = self.approvals {
319                    // If the label is in require_for, force approval required
320                    if approvals.require_for.iter().any(|r| r.label == rule.label) {
321                        require_approval = true;
322                    }
323                    // If the label is in auto_approve, override to false
324                    if approvals.auto_approve.iter().any(|r| r.label == rule.label) {
325                        require_approval = false;
326                    }
327                }
328
329                return Some(MatchResult {
330                    should_attest: true,
331                    label: rule.label.clone(),
332                    require_approval,
333                });
334            }
335        }
336        None
337    }
338}
339
340// ---------------------------------------------------------------------------
341// Tests
342// ---------------------------------------------------------------------------
343
344#[cfg(test)]
345mod tests {
346    use super::*;
347
348    const SAMPLE_YAML: &str = r#"
349treeship:
350  version: 1
351
352session:
353  actor: agent://test-coder
354  auto_start: true
355  auto_checkpoint: true
356
357attest:
358  commands:
359    - pattern: "npm test*"
360      label: test suite
361    - pattern: "cargo test*"
362      label: test suite
363    - pattern: "git commit*"
364      label: code commit
365    - pattern: "git push*"
366      label: code push
367    - pattern: "kubectl apply*"
368      label: deployment
369      require_approval: true
370    - pattern: "fly deploy*"
371      label: deployment
372      require_approval: true
373    - pattern: "stripe*"
374      label: payment
375      require_approval: true
376  paths:
377    - path: "src/**"
378      on: write
379    - path: "*lock*"
380      on: change
381      label: dependency change
382    - path: "*.env*"
383      on: access
384      label: env file access
385      alert: true
386
387approvals:
388  require_for:
389    - label: deployment
390    - label: payment
391  auto_approve:
392    - label: test suite
393    - label: code commit
394  timeout: 5m
395
396hub:
397  endpoint: https://api.treeship.dev
398  auto_push: true
399  push_on:
400    - session_close
401    - approval_required
402"#;
403
404    fn load_sample() -> ProjectConfig {
405        ProjectConfig::from_yaml(SAMPLE_YAML).expect("sample YAML should parse")
406    }
407
408    #[test]
409    fn test_load_from_yaml_string() {
410        let cfg = load_sample();
411        assert_eq!(cfg.treeship.version, 1);
412        assert_eq!(cfg.session.actor, "agent://test-coder");
413        assert!(cfg.session.auto_start);
414        assert_eq!(cfg.attest.commands.len(), 7);
415        assert_eq!(cfg.attest.paths.len(), 3);
416        assert!(cfg.approvals.is_some());
417        assert!(cfg.hub.is_some());
418    }
419
420    #[test]
421    fn test_command_match_prefix_wildcard() {
422        let cfg = load_sample();
423        let m = cfg.match_command("npm test").expect("should match");
424        assert_eq!(m.label, "test suite");
425        assert!(m.should_attest);
426    }
427
428    #[test]
429    fn test_command_match_prefix_wildcard_with_args() {
430        let cfg = load_sample();
431        let m = cfg
432            .match_command("npm test --coverage")
433            .expect("should match");
434        assert_eq!(m.label, "test suite");
435        assert!(m.should_attest);
436    }
437
438    #[test]
439    fn test_command_match_cargo_test() {
440        let cfg = load_sample();
441        let m = cfg
442            .match_command("cargo test -p treeship-core")
443            .expect("should match");
444        assert_eq!(m.label, "test suite");
445    }
446
447    #[test]
448    fn test_no_match_returns_none() {
449        let cfg = load_sample();
450        assert!(cfg.match_command("echo hello").is_none());
451        assert!(cfg.match_command("ls -la").is_none());
452        assert!(cfg.match_command("").is_none());
453    }
454
455    #[test]
456    fn test_require_approval_from_rule() {
457        let cfg = load_sample();
458        let m = cfg
459            .match_command("kubectl apply -f deploy.yaml")
460            .expect("should match");
461        assert_eq!(m.label, "deployment");
462        assert!(m.require_approval);
463    }
464
465    #[test]
466    fn test_auto_approve_overrides_require() {
467        // "test suite" is in both require_for (it's not, actually) and
468        // auto_approve. Since it's in auto_approve, require_approval should
469        // be false even though the rule itself does not set it.
470        let cfg = load_sample();
471        let m = cfg.match_command("npm test").expect("should match");
472        assert!(!m.require_approval, "test suite is auto-approved");
473    }
474
475    #[test]
476    fn test_require_for_forces_approval() {
477        // "payment" label is in require_for. Even though the rule already
478        // has require_approval: true, the approval config confirms it.
479        let cfg = load_sample();
480        let m = cfg
481            .match_command("stripe charge create")
482            .expect("should match");
483        assert_eq!(m.label, "payment");
484        assert!(m.require_approval);
485    }
486
487    #[test]
488    fn test_auto_approve_beats_require_for() {
489        // Build a config where a label appears in both require_for AND
490        // auto_approve. auto_approve should win (it's checked second).
491        let yaml = r#"
492treeship:
493  version: 1
494session:
495  actor: agent://test
496attest:
497  commands:
498    - pattern: "deploy*"
499      label: ops
500approvals:
501  require_for:
502    - label: ops
503  auto_approve:
504    - label: ops
505"#;
506        let cfg = ProjectConfig::from_yaml(yaml).unwrap();
507        let m = cfg.match_command("deploy production").unwrap();
508        assert!(
509            !m.require_approval,
510            "auto_approve should override require_for"
511        );
512    }
513
514    #[test]
515    fn test_no_approvals_section() {
516        let yaml = r#"
517treeship:
518  version: 1
519session:
520  actor: agent://test
521attest:
522  commands:
523    - pattern: "npm test*"
524      label: test suite
525"#;
526        let cfg = ProjectConfig::from_yaml(yaml).unwrap();
527        let m = cfg.match_command("npm test").unwrap();
528        assert!(!m.require_approval);
529    }
530
531    #[test]
532    fn test_missing_optional_fields() {
533        // Minimal config -- no hub, no approvals, no paths
534        let yaml = r#"
535treeship:
536  version: 1
537session:
538  actor: agent://minimal
539attest:
540  commands: []
541"#;
542        let cfg = ProjectConfig::from_yaml(yaml).unwrap();
543        assert!(cfg.hub.is_none());
544        assert!(cfg.approvals.is_none());
545        assert!(cfg.attest.paths.is_empty());
546        assert!(cfg.attest.commands.is_empty());
547    }
548
549    #[test]
550    fn test_default_for_node() {
551        let cfg = ProjectConfig::default_for("node", "agent://my-coder");
552        assert_eq!(cfg.treeship.version, 1);
553        assert_eq!(cfg.session.actor, "agent://my-coder");
554        assert!(cfg.session.auto_start);
555
556        // Should have npm test pattern
557        let m = cfg
558            .match_command("npm test --watch")
559            .expect("should match npm test");
560        assert_eq!(m.label, "test suite");
561        assert!(!m.require_approval, "tests are auto-approved by default");
562
563        // Should have deployment rules
564        let m = cfg
565            .match_command("kubectl apply -f x.yaml")
566            .expect("should match kubectl");
567        assert!(m.require_approval);
568    }
569
570    #[test]
571    fn test_default_for_rust() {
572        let cfg = ProjectConfig::default_for("rust", "agent://builder");
573        let m = cfg
574            .match_command("cargo test -p core")
575            .expect("should match cargo test");
576        assert_eq!(m.label, "test suite");
577    }
578
579    #[test]
580    fn test_default_for_python() {
581        let cfg = ProjectConfig::default_for("python", "agent://py");
582        let m = cfg.match_command("pytest -v").expect("should match pytest");
583        assert_eq!(m.label, "test suite");
584    }
585
586    #[test]
587    fn test_default_for_general() {
588        let cfg = ProjectConfig::default_for("general", "agent://dev");
589        // General has no test commands but still has git/deploy rules
590        let m = cfg
591            .match_command("git commit -m 'init'")
592            .expect("should match git commit");
593        assert_eq!(m.label, "code commit");
594    }
595
596    #[test]
597    fn test_wildcard_suffix_match() {
598        // Test suffix matching with * at the start
599        let yaml = r#"
600treeship:
601  version: 1
602session:
603  actor: agent://test
604attest:
605  commands:
606    - pattern: "*.rs"
607      label: rust file
608"#;
609        let cfg = ProjectConfig::from_yaml(yaml).unwrap();
610        let m = cfg.match_command("compile main.rs").unwrap();
611        assert_eq!(m.label, "rust file");
612        assert!(cfg.match_command("main.py").is_none());
613    }
614
615    #[test]
616    fn test_wildcard_exact_match() {
617        let yaml = r#"
618treeship:
619  version: 1
620session:
621  actor: agent://test
622attest:
623  commands:
624    - pattern: "make"
625      label: build
626"#;
627        let cfg = ProjectConfig::from_yaml(yaml).unwrap();
628        assert!(cfg.match_command("make").is_some());
629        assert!(cfg.match_command("make install").is_none());
630        assert!(cfg.match_command("cmake").is_none());
631    }
632
633    #[test]
634    fn test_first_matching_rule_wins() {
635        let yaml = r#"
636treeship:
637  version: 1
638session:
639  actor: agent://test
640attest:
641  commands:
642    - pattern: "npm test*"
643      label: test suite
644    - pattern: "npm*"
645      label: npm command
646"#;
647        let cfg = ProjectConfig::from_yaml(yaml).unwrap();
648        let m = cfg.match_command("npm test --ci").unwrap();
649        assert_eq!(m.label, "test suite", "first matching rule should win");
650    }
651
652    #[test]
653    fn test_hub_config_fields() {
654        let cfg = load_sample();
655        let hub = cfg.hub.as_ref().unwrap();
656        assert_eq!(hub.endpoint.as_deref(), Some("https://api.treeship.dev"));
657        assert!(hub.auto_push);
658        assert_eq!(hub.push_on, vec!["session_close", "approval_required"]);
659    }
660
661    #[test]
662    fn test_path_rules_parsed() {
663        let cfg = load_sample();
664        assert_eq!(cfg.attest.paths.len(), 3);
665        let env_rule = &cfg.attest.paths[2];
666        assert_eq!(env_rule.path, "*.env*");
667        assert_eq!(env_rule.on, "access");
668        assert!(env_rule.alert);
669        assert_eq!(env_rule.label.as_deref(), Some("env file access"));
670    }
671
672    #[test]
673    fn test_path_match_directory_glob() {
674        let cfg = load_sample();
675        let m = cfg.match_path("src/foo.rs").expect("should match src/**");
676        assert_eq!(m.label, "file change"); // no label set for src/**
677        assert_eq!(m.on, "write");
678    }
679
680    #[test]
681    fn test_path_match_directory_glob_nested() {
682        let cfg = load_sample();
683        let m = cfg
684            .match_path("src/bar/baz.ts")
685            .expect("should match src/**");
686        assert_eq!(m.on, "write");
687    }
688
689    #[test]
690    fn test_path_match_lock_wildcard() {
691        let cfg = load_sample();
692        let m = cfg
693            .match_path("package-lock.json")
694            .expect("should match *lock*");
695        assert_eq!(m.label, "dependency change");
696        assert_eq!(m.on, "change");
697    }
698
699    #[test]
700    fn test_path_match_cargo_lock() {
701        let cfg = load_sample();
702        let m = cfg.match_path("Cargo.lock").expect("should match *lock*");
703        assert_eq!(m.label, "dependency change");
704    }
705
706    #[test]
707    fn test_path_match_env_file() {
708        let cfg = load_sample();
709        let m = cfg.match_path(".env").expect("should match *.env*");
710        assert_eq!(m.label, "env file access");
711        assert!(m.alert);
712        assert_eq!(m.on, "access");
713    }
714
715    #[test]
716    fn test_path_match_env_local() {
717        let cfg = load_sample();
718        let m = cfg.match_path(".env.local").expect("should match *.env*");
719        assert_eq!(m.label, "env file access");
720        assert!(m.alert);
721    }
722
723    #[test]
724    fn test_path_no_match() {
725        let cfg = load_sample();
726        assert!(cfg.match_path("README.md").is_none());
727        assert!(cfg.match_path("docs/intro.txt").is_none());
728    }
729
730    #[test]
731    fn test_path_match_first_rule_wins() {
732        // src/foo.rs matches "src/**" first
733        let cfg = load_sample();
734        let m = cfg.match_path("src/foo.rs").unwrap();
735        assert_eq!(m.on, "write");
736    }
737}