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