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 let Some(prefix) = pattern.strip_suffix("/**") {
146 return path.starts_with(prefix);
147 }
148 wildcard_match(pattern, path)
150}
151
152impl ProjectConfig {
157 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 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 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 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 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 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 if let Some(ref approvals) = self.approvals {
319 if approvals.require_for.iter().any(|r| r.label == rule.label) {
321 require_approval = true;
322 }
323 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#[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 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 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 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 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 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 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 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 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"); 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 let cfg = load_sample();
734 let m = cfg.match_path("src/foo.rs").unwrap();
735 assert_eq!(m.on, "write");
736 }
737}