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 { 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 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 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 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 if let Some(ref approvals) = self.approvals {
256 if approvals.require_for.iter().any(|r| r.label == rule.label) {
258 require_approval = true;
259 }
260 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#[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 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 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 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 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 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 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 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 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"); 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 let cfg = load_sample();
648 let m = cfg.match_path("src/foo.rs").unwrap();
649 assert_eq!(m.on, "write");
650 }
651}