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