1use serde::{Deserialize, Serialize};
2
3#[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#[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#[derive(Debug, Clone, PartialEq, Eq)]
101pub struct PathMatchResult {
102 pub label: String,
103 pub alert: bool,
104 pub on: String,
105}
106
107fn wildcard_match(pattern: &str, value: &str) -> bool {
118 if pattern.ends_with('*') && !pattern.starts_with('*') {
119 let prefix = &pattern[..pattern.len() - 1];
121 value.starts_with(prefix)
122 } else if pattern.starts_with('*') && !pattern.ends_with('*') {
123 let suffix = &pattern[1..];
125 value.ends_with(suffix)
126 } else if pattern.starts_with('*') && pattern.ends_with('*') {
127 let inner = &pattern[1..pattern.len() - 1];
129 value.contains(inner)
130 } else {
131 pattern == value
133 }
134}
135
136fn path_matches(pattern: &str, path: &str) -> bool {
144 if pattern.ends_with("/**") {
146 let prefix = &pattern[..pattern.len() - 3];
147 return path.starts_with(prefix);
148 }
149 wildcard_match(pattern, path)
151}
152
153impl ProjectConfig {
158 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 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 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 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 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 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 if let Some(ref approvals) = self.approvals {
320 if approvals.require_for.iter().any(|r| r.label == rule.label) {
322 require_approval = true;
323 }
324 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#[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 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 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 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 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 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 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 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 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"); 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 let cfg = load_sample();
735 let m = cfg.match_path("src/foo.rs").unwrap();
736 assert_eq!(m.on, "write");
737 }
738}