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