1use std::collections::BTreeMap;
5
6use chrono::{DateTime, Utc};
7use serde::{Deserialize, Deserializer, Serialize, Serializer};
8
9use super::config::InteractionLevel;
10use super::item::Capability;
11
12#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
13pub struct Project {
14 pub name: String,
15 #[serde(default, skip_serializing_if = "Option::is_none")]
16 pub acronym: Option<String>,
17 #[serde(default, skip_serializing_if = "Option::is_none")]
18 pub description: Option<String>,
19 #[serde(default = "default_language")]
20 pub language: String,
21 #[serde(default, skip_serializing_if = "Option::is_none")]
22 pub forge: Option<String>,
23 #[serde(default, skip_serializing_if = "Docs::is_empty")]
24 pub docs: Docs,
25 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
26 pub members: BTreeMap<String, Member>,
27 pub created: DateTime<Utc>,
28}
29
30#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
34pub struct Docs {
35 #[serde(default, skip_serializing_if = "Option::is_none")]
36 pub architecture: Option<String>,
37 #[serde(default, skip_serializing_if = "Option::is_none")]
38 pub vision: Option<String>,
39 #[serde(default, skip_serializing_if = "Option::is_none")]
40 pub contributing: Option<String>,
41}
42
43impl Docs {
44 pub const DEFAULT_ARCHITECTURE: &'static str = "docs/dev/architecture/README.md";
45 pub const DEFAULT_VISION: &'static str = "docs/dev/vision/README.md";
46 pub const DEFAULT_CONTRIBUTING: &'static str = "CONTRIBUTING.md";
47
48 pub fn is_empty(&self) -> bool {
49 self.architecture.is_none() && self.vision.is_none() && self.contributing.is_none()
50 }
51
52 pub fn architecture_or_default(&self) -> &str {
54 self.architecture
55 .as_deref()
56 .unwrap_or(Self::DEFAULT_ARCHITECTURE)
57 }
58
59 pub fn vision_or_default(&self) -> &str {
61 self.vision.as_deref().unwrap_or(Self::DEFAULT_VISION)
62 }
63
64 pub fn contributing_or_default(&self) -> &str {
66 self.contributing
67 .as_deref()
68 .unwrap_or(Self::DEFAULT_CONTRIBUTING)
69 }
70}
71
72#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
73pub struct Member {
74 pub capabilities: MemberCapabilities,
75 #[serde(default, skip_serializing_if = "Option::is_none")]
76 pub public_key: Option<String>,
77 #[serde(default, skip_serializing_if = "Option::is_none")]
78 pub salt: Option<String>,
79 #[serde(default, skip_serializing_if = "Option::is_none")]
80 pub otp_hash: Option<String>,
81 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
82 pub ai_delegations: BTreeMap<String, AiDelegationEntry>,
83 #[serde(default, skip_serializing_if = "Option::is_none")]
84 pub attestation: Option<Attestation>,
85}
86
87#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
94pub struct Attestation {
95 pub attester: String,
98 pub signed_fields: AttestationSignedFields,
102 pub signed_at: chrono::DateTime<chrono::Utc>,
104 pub signature: String,
107}
108
109#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
112pub struct AttestationSignedFields {
113 pub email: String,
114 pub capabilities: MemberCapabilities,
115 #[serde(default, skip_serializing_if = "Option::is_none")]
116 pub otp_hash: Option<String>,
117}
118
119impl AttestationSignedFields {
120 pub fn canonical_bytes(&self) -> Vec<u8> {
125 serde_json::to_vec(self).expect("AttestationSignedFields canonicalization")
126 }
127}
128
129#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
133pub struct AiDelegationEntry {
134 pub delegation_key: String,
136 pub created: chrono::DateTime<chrono::Utc>,
138 #[serde(default, skip_serializing_if = "Option::is_none")]
140 pub rotated: Option<chrono::DateTime<chrono::Utc>>,
141}
142
143#[derive(Debug, Clone, PartialEq)]
144pub enum MemberCapabilities {
145 All,
146 Specific(BTreeMap<Capability, CapabilityConfig>),
147}
148
149#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
150pub struct CapabilityConfig {
151 #[serde(rename = "max-mode", default, skip_serializing_if = "Option::is_none")]
152 pub max_mode: Option<InteractionLevel>,
153 #[serde(
154 rename = "max-cost-per-job",
155 default,
156 skip_serializing_if = "Option::is_none"
157 )]
158 pub max_cost_per_job: Option<f64>,
159}
160
161#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
168pub struct ModeDefaults {
169 #[serde(default)]
171 pub default: InteractionLevel,
172 #[serde(flatten, default)]
174 pub capabilities: BTreeMap<Capability, InteractionLevel>,
175}
176
177#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
180pub struct AiDefaults {
181 #[serde(default, skip_serializing_if = "Vec::is_empty")]
182 pub capabilities: Vec<Capability>,
183}
184
185#[derive(Debug, Clone, Copy, PartialEq, Eq)]
187pub enum ModeSource {
188 Default,
190 Project,
192 Personal,
194 Item,
196 ProjectMax,
198}
199
200impl std::fmt::Display for ModeSource {
201 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
202 match self {
203 Self::Default => write!(f, "default"),
204 Self::Project => write!(f, "project"),
205 Self::Personal => write!(f, "personal"),
206 Self::Item => write!(f, "item"),
207 Self::ProjectMax => write!(f, "project max"),
208 }
209 }
210}
211
212pub fn resolve_mode(
221 capability: &Capability,
222 raw_defaults: &ModeDefaults,
223 effective_defaults: &ModeDefaults,
224 personal_mode: Option<InteractionLevel>,
225 member_cap_config: Option<&CapabilityConfig>,
226) -> (InteractionLevel, ModeSource) {
227 let mut mode = effective_defaults.default;
229 let mut source = if effective_defaults.default != raw_defaults.default {
230 ModeSource::Project
231 } else {
232 ModeSource::Default
233 };
234
235 if let Some(&cap_mode) = effective_defaults.capabilities.get(capability) {
237 mode = cap_mode;
238 let from_raw = raw_defaults.capabilities.get(capability) == Some(&cap_mode);
239 source = if from_raw {
240 ModeSource::Default
241 } else {
242 ModeSource::Project
243 };
244 }
245
246 if let Some(personal) = personal_mode {
248 mode = personal;
249 source = ModeSource::Personal;
250 }
251
252 if let Some(cap_config) = member_cap_config {
254 if let Some(max) = cap_config.max_mode {
255 if mode < max {
256 mode = max;
257 source = ModeSource::ProjectMax;
258 }
259 }
260 }
261
262 (mode, source)
263}
264
265impl Serialize for MemberCapabilities {
267 fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
268 match self {
269 MemberCapabilities::All => serializer.serialize_str("all"),
270 MemberCapabilities::Specific(map) => map.serialize(serializer),
271 }
272 }
273}
274
275impl<'de> Deserialize<'de> for MemberCapabilities {
276 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
277 let value = serde_yaml_ng::Value::deserialize(deserializer)?;
278 match &value {
279 serde_yaml_ng::Value::String(s) if s == "all" => Ok(MemberCapabilities::All),
280 serde_yaml_ng::Value::Mapping(_) => {
281 let map: BTreeMap<Capability, CapabilityConfig> =
282 serde_yaml_ng::from_value(value).map_err(serde::de::Error::custom)?;
283 Ok(MemberCapabilities::Specific(map))
284 }
285 _ => Err(serde::de::Error::custom(
286 "expected \"all\" or a map of capabilities",
287 )),
288 }
289 }
290}
291
292impl Member {
293 pub fn new(capabilities: MemberCapabilities) -> Self {
295 Self {
296 capabilities,
297 public_key: None,
298 salt: None,
299 otp_hash: None,
300 ai_delegations: BTreeMap::new(),
301 attestation: None,
302 }
303 }
304
305 pub fn has_capability(&self, cap: &Capability) -> bool {
307 match &self.capabilities {
308 MemberCapabilities::All => true,
309 MemberCapabilities::Specific(map) => map.contains_key(cap),
310 }
311 }
312}
313
314pub fn is_ai_member(id: &str) -> bool {
316 id.starts_with("ai:")
317}
318
319fn default_language() -> String {
320 "en".to_string()
321}
322
323impl Project {
324 pub fn new(name: String, acronym: Option<String>) -> Self {
325 Self {
326 name,
327 acronym,
328 description: None,
329 language: default_language(),
330 forge: None,
331 docs: Docs::default(),
332 members: BTreeMap::new(),
333 created: Utc::now(),
334 }
335 }
336}
337
338pub fn validate_acronym(value: &str) -> Result<String, String> {
345 let normalized = value.trim().to_uppercase();
346 if normalized.len() < 2 || normalized.len() > 8 {
347 return Err(format!(
348 "acronym must be 2-8 characters, got {} ('{}')",
349 normalized.len(),
350 normalized
351 ));
352 }
353 for (i, c) in normalized.chars().enumerate() {
354 if !(c.is_ascii_uppercase() || c.is_ascii_digit()) {
355 return Err(format!(
356 "acronym character '{c}' at position {i} is not A-Z or 0-9"
357 ));
358 }
359 }
360 Ok(normalized)
361}
362
363pub fn derive_acronym(name: &str) -> String {
367 let words: Vec<&str> = name.split_whitespace().collect();
368 if words.len() == 1 {
369 words[0]
370 .chars()
371 .filter(|c| c.is_alphanumeric())
372 .take(3)
373 .collect::<String>()
374 .to_uppercase()
375 } else {
376 words
377 .iter()
378 .filter_map(|w| w.chars().next())
379 .filter(|c| c.is_alphanumeric())
380 .take(4)
381 .collect::<String>()
382 .to_uppercase()
383 }
384}
385
386#[cfg(test)]
387mod tests {
388 use super::*;
389
390 #[test]
391 fn project_roundtrip() {
392 let project = Project::new("Test Project".into(), Some("TP".into()));
393 let yaml = serde_yaml_ng::to_string(&project).unwrap();
394 let parsed: Project = serde_yaml_ng::from_str(&yaml).unwrap();
395 assert_eq!(project, parsed);
396 }
397
398 #[test]
403 fn ai_delegations_omitted_when_empty() {
404 let mut m = Member::new(MemberCapabilities::All);
405 assert!(m.ai_delegations.is_empty());
406 let yaml = serde_yaml_ng::to_string(&m).unwrap();
407 assert!(
408 !yaml.contains("ai_delegations"),
409 "empty ai_delegations should be skipped, got: {yaml}"
410 );
411 m.public_key = Some("aa".repeat(32));
413 let yaml = serde_yaml_ng::to_string(&m).unwrap();
414 let parsed: Member = serde_yaml_ng::from_str(&yaml).unwrap();
415 assert_eq!(m, parsed);
416 }
417
418 #[test]
419 fn ai_delegations_yaml_roundtrip() {
420 let mut m = Member::new(MemberCapabilities::All);
421 m.public_key = Some("aa".repeat(32));
422 m.salt = Some("bb".repeat(32));
423 m.ai_delegations.insert(
424 "ai:claude@joy".into(),
425 AiDelegationEntry {
426 delegation_key: "cc".repeat(32),
427 created: chrono::DateTime::parse_from_rfc3339("2026-04-15T10:00:00Z")
428 .unwrap()
429 .with_timezone(&chrono::Utc),
430 rotated: None,
431 },
432 );
433 let yaml = serde_yaml_ng::to_string(&m).unwrap();
434 assert!(yaml.contains("ai_delegations:"));
435 assert!(yaml.contains("ai:claude@joy:"));
436 assert!(yaml.contains("delegation_key:"));
437 assert!(
438 !yaml.contains("rotated:"),
439 "unset rotated should be skipped"
440 );
441
442 let parsed: Member = serde_yaml_ng::from_str(&yaml).unwrap();
443 assert_eq!(m, parsed);
444 }
445
446 #[test]
447 fn ai_delegations_with_rotated_roundtrips() {
448 let mut m = Member::new(MemberCapabilities::All);
449 let created = chrono::DateTime::parse_from_rfc3339("2026-04-01T10:00:00Z")
450 .unwrap()
451 .with_timezone(&chrono::Utc);
452 let rotated = chrono::DateTime::parse_from_rfc3339("2026-04-15T12:30:00Z")
453 .unwrap()
454 .with_timezone(&chrono::Utc);
455 m.ai_delegations.insert(
456 "ai:claude@joy".into(),
457 AiDelegationEntry {
458 delegation_key: "dd".repeat(32),
459 created,
460 rotated: Some(rotated),
461 },
462 );
463 let yaml = serde_yaml_ng::to_string(&m).unwrap();
464 assert!(yaml.contains("rotated:"));
465 let parsed: Member = serde_yaml_ng::from_str(&yaml).unwrap();
466 assert_eq!(m.ai_delegations["ai:claude@joy"].rotated, Some(rotated));
467 assert_eq!(parsed, m);
468 }
469
470 #[test]
475 fn attestation_omitted_when_none() {
476 let m = Member::new(MemberCapabilities::All);
477 let yaml = serde_yaml_ng::to_string(&m).unwrap();
478 assert!(!yaml.contains("attestation:"));
479 }
480
481 #[test]
482 fn attestation_yaml_roundtrips() {
483 let mut m = Member::new(MemberCapabilities::All);
484 m.attestation = Some(Attestation {
485 attester: "horst@example.com".into(),
486 signed_fields: AttestationSignedFields {
487 email: "alice@example.com".into(),
488 capabilities: MemberCapabilities::All,
489 otp_hash: Some("ff".repeat(32)),
490 },
491 signed_at: chrono::DateTime::parse_from_rfc3339("2026-04-20T10:00:00Z")
492 .unwrap()
493 .with_timezone(&chrono::Utc),
494 signature: "aa".repeat(32),
495 });
496 let yaml = serde_yaml_ng::to_string(&m).unwrap();
497 assert!(yaml.contains("attestation:"));
498 assert!(yaml.contains("attester: horst@example.com"));
499 let parsed: Member = serde_yaml_ng::from_str(&yaml).unwrap();
500 assert_eq!(parsed, m);
501 }
502
503 #[test]
504 fn attestation_signed_fields_canonical_is_deterministic() {
505 let a = AttestationSignedFields {
506 email: "alice@example.com".into(),
507 capabilities: MemberCapabilities::All,
508 otp_hash: Some("abc".into()),
509 };
510 let b = a.clone();
511 assert_eq!(a.canonical_bytes(), b.canonical_bytes());
512 }
513
514 #[test]
515 fn attestation_signed_fields_differ_on_capability_change() {
516 let a = AttestationSignedFields {
517 email: "alice@example.com".into(),
518 capabilities: MemberCapabilities::All,
519 otp_hash: None,
520 };
521 let mut caps = BTreeMap::new();
522 caps.insert(Capability::Implement, CapabilityConfig::default());
523 let b = AttestationSignedFields {
524 email: "alice@example.com".into(),
525 capabilities: MemberCapabilities::Specific(caps),
526 otp_hash: None,
527 };
528 assert_ne!(a.canonical_bytes(), b.canonical_bytes());
529 }
530
531 #[test]
532 fn unknown_fields_from_legacy_yaml_are_ignored() {
533 let yaml = r#"
537capabilities: all
538public_key: aa
539salt: bb
540ai_tokens:
541 ai:claude@joy:
542 token_key: oldkey
543 created: "2026-03-28T22:00:00Z"
544ai_delegations:
545 ai:claude@joy:
546 delegation_key: newkey
547 created: "2026-04-15T10:00:00Z"
548"#;
549 let parsed: Member = serde_yaml_ng::from_str(yaml).unwrap();
550 assert_eq!(
551 parsed.ai_delegations["ai:claude@joy"].delegation_key,
552 "newkey"
553 );
554 }
555
556 #[test]
561 fn docs_defaults_when_unset() {
562 let docs = Docs::default();
563 assert_eq!(docs.architecture_or_default(), Docs::DEFAULT_ARCHITECTURE);
564 assert_eq!(docs.vision_or_default(), Docs::DEFAULT_VISION);
565 assert_eq!(docs.contributing_or_default(), Docs::DEFAULT_CONTRIBUTING);
566 }
567
568 #[test]
569 fn docs_returns_configured_value() {
570 let docs = Docs {
571 architecture: Some("ARCHITECTURE.md".into()),
572 vision: Some("docs/product/vision.md".into()),
573 contributing: None,
574 };
575 assert_eq!(docs.architecture_or_default(), "ARCHITECTURE.md");
576 assert_eq!(docs.vision_or_default(), "docs/product/vision.md");
577 assert_eq!(docs.contributing_or_default(), Docs::DEFAULT_CONTRIBUTING);
578 }
579
580 #[test]
581 fn docs_omitted_from_yaml_when_empty() {
582 let project = Project::new("X".into(), None);
583 let yaml = serde_yaml_ng::to_string(&project).unwrap();
584 assert!(
585 !yaml.contains("docs:"),
586 "empty docs should be skipped, got: {yaml}"
587 );
588 }
589
590 #[test]
591 fn docs_present_in_yaml_when_set() {
592 let mut project = Project::new("X".into(), None);
593 project.docs.architecture = Some("ARCHITECTURE.md".into());
594 let yaml = serde_yaml_ng::to_string(&project).unwrap();
595 assert!(yaml.contains("docs:"), "docs block expected: {yaml}");
596 assert!(yaml.contains("architecture: ARCHITECTURE.md"));
597 assert!(!yaml.contains("vision:"), "unset fields should be skipped");
598 }
599
600 #[test]
601 fn docs_yaml_roundtrip_with_overrides() {
602 let yaml = r#"
603name: Existing
604language: en
605docs:
606 architecture: ARCHITECTURE.md
607 contributing: docs/CONTRIBUTING.md
608created: 2026-01-01T00:00:00Z
609"#;
610 let parsed: Project = serde_yaml_ng::from_str(yaml).unwrap();
611 assert_eq!(parsed.docs.architecture.as_deref(), Some("ARCHITECTURE.md"));
612 assert_eq!(parsed.docs.vision, None);
613 assert_eq!(
614 parsed.docs.contributing.as_deref(),
615 Some("docs/CONTRIBUTING.md")
616 );
617 assert_eq!(parsed.docs.vision_or_default(), Docs::DEFAULT_VISION);
618 }
619
620 #[test]
621 fn derive_acronym_multi_word() {
622 assert_eq!(derive_acronym("My Cool Project"), "MCP");
623 }
624
625 #[test]
626 fn derive_acronym_single_word() {
627 assert_eq!(derive_acronym("Joy"), "JOY");
628 }
629
630 #[test]
631 fn derive_acronym_long_name() {
632 assert_eq!(derive_acronym("A Very Long Project Name"), "AVLP");
633 }
634
635 #[test]
636 fn derive_acronym_single_long_word() {
637 assert_eq!(derive_acronym("Platform"), "PLA");
638 }
639
640 #[test]
645 fn validate_acronym_accepts_real_project_acronyms() {
646 for a in ["JI", "JOT", "JOY", "JON", "JP", "JAPP", "JOYC", "JISITE"] {
647 assert_eq!(validate_acronym(a).unwrap(), a, "rejected real acronym {a}");
648 }
649 }
650
651 #[test]
652 fn validate_acronym_accepts_alphanumeric() {
653 assert_eq!(validate_acronym("V2").unwrap(), "V2");
654 assert_eq!(validate_acronym("A1B2").unwrap(), "A1B2");
655 }
656
657 #[test]
658 fn validate_acronym_normalizes_case_and_whitespace() {
659 assert_eq!(validate_acronym("jyn").unwrap(), "JYN");
660 assert_eq!(validate_acronym("Jyn").unwrap(), "JYN");
661 assert_eq!(validate_acronym(" jyn ").unwrap(), "JYN");
662 }
663
664 #[test]
665 fn validate_acronym_rejects_too_short() {
666 assert!(validate_acronym("").is_err());
667 assert!(validate_acronym("J").is_err());
668 assert!(validate_acronym(" J ").is_err());
669 }
670
671 #[test]
672 fn validate_acronym_rejects_too_long() {
673 assert!(validate_acronym("ABCDEFGHI").is_err());
674 }
675
676 #[test]
677 fn validate_acronym_rejects_non_alnum() {
678 assert!(validate_acronym("JY-N").is_err());
679 assert!(validate_acronym("JY N").is_err());
680 assert!(validate_acronym("JY_N").is_err());
681 assert!(validate_acronym("JY.N").is_err());
682 }
683
684 #[test]
685 fn validate_acronym_rejects_non_ascii() {
686 assert!(validate_acronym("AEBC").is_ok());
687 assert!(validate_acronym("ABC").is_ok());
688 assert!(validate_acronym("\u{00c4}BC").is_err());
689 }
690
691 #[test]
696 fn mode_defaults_flat_yaml_roundtrip() {
697 let yaml = r#"
698default: interactive
699implement: collaborative
700review: pairing
701"#;
702 let parsed: ModeDefaults = serde_yaml_ng::from_str(yaml).unwrap();
703 assert_eq!(parsed.default, InteractionLevel::Interactive);
704 assert_eq!(
705 parsed.capabilities[&Capability::Implement],
706 InteractionLevel::Collaborative
707 );
708 assert_eq!(
709 parsed.capabilities[&Capability::Review],
710 InteractionLevel::Pairing
711 );
712 }
713
714 #[test]
715 fn mode_defaults_empty_yaml() {
716 let yaml = "{}";
717 let parsed: ModeDefaults = serde_yaml_ng::from_str(yaml).unwrap();
718 assert_eq!(parsed.default, InteractionLevel::Collaborative);
719 assert!(parsed.capabilities.is_empty());
720 }
721
722 #[test]
723 fn mode_defaults_only_default() {
724 let yaml = "default: pairing";
725 let parsed: ModeDefaults = serde_yaml_ng::from_str(yaml).unwrap();
726 assert_eq!(parsed.default, InteractionLevel::Pairing);
727 assert!(parsed.capabilities.is_empty());
728 }
729
730 #[test]
731 fn ai_defaults_yaml_roundtrip() {
732 let yaml = r#"
733capabilities:
734 - implement
735 - review
736"#;
737 let parsed: AiDefaults = serde_yaml_ng::from_str(yaml).unwrap();
738 assert_eq!(parsed.capabilities.len(), 2);
739 assert_eq!(parsed.capabilities[0], Capability::Implement);
740 }
741
742 fn defaults_with_mode(mode: InteractionLevel) -> ModeDefaults {
747 ModeDefaults {
748 default: mode,
749 ..Default::default()
750 }
751 }
752
753 fn defaults_with_cap_mode(cap: Capability, mode: InteractionLevel) -> ModeDefaults {
754 let mut d = ModeDefaults::default();
755 d.capabilities.insert(cap, mode);
756 d
757 }
758
759 #[test]
760 fn resolve_mode_uses_global_default() {
761 let raw = defaults_with_mode(InteractionLevel::Collaborative);
762 let effective = raw.clone();
763 let (mode, source) = resolve_mode(&Capability::Implement, &raw, &effective, None, None);
764 assert_eq!(mode, InteractionLevel::Collaborative);
765 assert_eq!(source, ModeSource::Default);
766 }
767
768 #[test]
769 fn resolve_mode_uses_per_capability_default() {
770 let raw = defaults_with_cap_mode(Capability::Review, InteractionLevel::Interactive);
771 let effective = raw.clone();
772 let (mode, source) = resolve_mode(&Capability::Review, &raw, &effective, None, None);
773 assert_eq!(mode, InteractionLevel::Interactive);
774 assert_eq!(source, ModeSource::Default);
775 }
776
777 #[test]
778 fn resolve_mode_project_override_detected() {
779 let raw = defaults_with_cap_mode(Capability::Implement, InteractionLevel::Collaborative);
780 let effective =
781 defaults_with_cap_mode(Capability::Implement, InteractionLevel::Interactive);
782 let (mode, source) = resolve_mode(&Capability::Implement, &raw, &effective, None, None);
783 assert_eq!(mode, InteractionLevel::Interactive);
784 assert_eq!(source, ModeSource::Project);
785 }
786
787 #[test]
788 fn resolve_mode_personal_overrides_default() {
789 let raw = defaults_with_mode(InteractionLevel::Collaborative);
790 let effective = raw.clone();
791 let (mode, source) = resolve_mode(
792 &Capability::Implement,
793 &raw,
794 &effective,
795 Some(InteractionLevel::Pairing),
796 None,
797 );
798 assert_eq!(mode, InteractionLevel::Pairing);
799 assert_eq!(source, ModeSource::Personal);
800 }
801
802 #[test]
803 fn resolve_mode_max_mode_clamps_upward() {
804 let raw = defaults_with_mode(InteractionLevel::Autonomous);
805 let effective = raw.clone();
806 let cap_config = CapabilityConfig {
807 max_mode: Some(InteractionLevel::Supervised),
808 ..Default::default()
809 };
810 let (mode, source) = resolve_mode(
811 &Capability::Implement,
812 &raw,
813 &effective,
814 None,
815 Some(&cap_config),
816 );
817 assert_eq!(mode, InteractionLevel::Supervised);
818 assert_eq!(source, ModeSource::ProjectMax);
819 }
820
821 #[test]
822 fn resolve_mode_max_mode_does_not_lower() {
823 let raw = defaults_with_mode(InteractionLevel::Pairing);
824 let effective = raw.clone();
825 let cap_config = CapabilityConfig {
826 max_mode: Some(InteractionLevel::Supervised),
827 ..Default::default()
828 };
829 let (mode, source) = resolve_mode(
830 &Capability::Implement,
831 &raw,
832 &effective,
833 None,
834 Some(&cap_config),
835 );
836 assert_eq!(mode, InteractionLevel::Pairing);
838 assert_eq!(source, ModeSource::Default);
839 }
840
841 #[test]
842 fn resolve_mode_personal_clamped_by_max() {
843 let raw = defaults_with_mode(InteractionLevel::Collaborative);
844 let effective = raw.clone();
845 let cap_config = CapabilityConfig {
846 max_mode: Some(InteractionLevel::Interactive),
847 ..Default::default()
848 };
849 let (mode, source) = resolve_mode(
850 &Capability::Implement,
851 &raw,
852 &effective,
853 Some(InteractionLevel::Autonomous),
854 Some(&cap_config),
855 );
856 assert_eq!(mode, InteractionLevel::Interactive);
858 assert_eq!(source, ModeSource::ProjectMax);
859 }
860
861 #[test]
866 fn item_mode_field_roundtrip() {
867 use crate::model::item::{Item, ItemType, Priority};
868
869 let mut item = Item::new(
870 "TST-0001".into(),
871 "Test".into(),
872 ItemType::Task,
873 Priority::Medium,
874 vec![],
875 );
876 item.mode = Some(InteractionLevel::Pairing);
877
878 let yaml = serde_yaml_ng::to_string(&item).unwrap();
879 assert!(yaml.contains("mode: pairing"), "mode field not serialized");
880
881 let parsed: Item = serde_yaml_ng::from_str(&yaml).unwrap();
882 assert_eq!(parsed.mode, Some(InteractionLevel::Pairing));
883 }
884
885 #[test]
886 fn item_mode_field_absent_when_none() {
887 use crate::model::item::{Item, ItemType, Priority};
888
889 let item = Item::new(
890 "TST-0002".into(),
891 "Test".into(),
892 ItemType::Task,
893 Priority::Medium,
894 vec![],
895 );
896 assert_eq!(item.mode, None);
897
898 let yaml = serde_yaml_ng::to_string(&item).unwrap();
899 assert!(
900 !yaml.contains("mode:"),
901 "mode field should not appear when None"
902 );
903 }
904
905 #[test]
906 fn item_mode_deserialized_from_existing_yaml() {
907 let yaml = r#"
908id: TST-0003
909title: Test
910type: task
911status: new
912priority: medium
913mode: interactive
914created: "2026-01-01T00:00:00+00:00"
915updated: "2026-01-01T00:00:00+00:00"
916"#;
917 let item: crate::model::item::Item = serde_yaml_ng::from_str(yaml).unwrap();
918 assert_eq!(item.mode, Some(InteractionLevel::Interactive));
919 }
920
921 #[test]
926 fn resolve_mode_full_scenario() {
927 let raw = defaults_with_cap_mode(Capability::Implement, InteractionLevel::Collaborative);
929 let effective =
931 defaults_with_cap_mode(Capability::Implement, InteractionLevel::Interactive);
932 let personal = Some(InteractionLevel::Autonomous);
934 let cap_config = CapabilityConfig {
936 max_mode: Some(InteractionLevel::Supervised),
937 ..Default::default()
938 };
939
940 let (mode, source) = resolve_mode(
941 &Capability::Implement,
942 &raw,
943 &effective,
944 personal,
945 Some(&cap_config),
946 );
947
948 assert_eq!(mode, InteractionLevel::Supervised);
950 assert_eq!(source, ModeSource::ProjectMax);
951 }
952
953 #[test]
954 fn resolve_mode_all_layers_no_clamping() {
955 let raw = defaults_with_cap_mode(Capability::Implement, InteractionLevel::Collaborative);
957 let effective =
959 defaults_with_cap_mode(Capability::Implement, InteractionLevel::Interactive);
960 let personal = Some(InteractionLevel::Pairing);
962 let cap_config = CapabilityConfig::default();
964
965 let (mode, source) = resolve_mode(
966 &Capability::Implement,
967 &raw,
968 &effective,
969 personal,
970 Some(&cap_config),
971 );
972
973 assert_eq!(mode, InteractionLevel::Pairing);
975 assert_eq!(source, ModeSource::Personal);
976 }
977}