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}
84
85#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
89pub struct AiDelegationEntry {
90 pub delegation_key: String,
92 pub created: chrono::DateTime<chrono::Utc>,
94 #[serde(default, skip_serializing_if = "Option::is_none")]
96 pub rotated: Option<chrono::DateTime<chrono::Utc>>,
97}
98
99#[derive(Debug, Clone, PartialEq)]
100pub enum MemberCapabilities {
101 All,
102 Specific(BTreeMap<Capability, CapabilityConfig>),
103}
104
105#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
106pub struct CapabilityConfig {
107 #[serde(rename = "max-mode", default, skip_serializing_if = "Option::is_none")]
108 pub max_mode: Option<InteractionLevel>,
109 #[serde(
110 rename = "max-cost-per-job",
111 default,
112 skip_serializing_if = "Option::is_none"
113 )]
114 pub max_cost_per_job: Option<f64>,
115}
116
117#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
124pub struct ModeDefaults {
125 #[serde(default)]
127 pub default: InteractionLevel,
128 #[serde(flatten, default)]
130 pub capabilities: BTreeMap<Capability, InteractionLevel>,
131}
132
133#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
136pub struct AiDefaults {
137 #[serde(default, skip_serializing_if = "Vec::is_empty")]
138 pub capabilities: Vec<Capability>,
139}
140
141#[derive(Debug, Clone, Copy, PartialEq, Eq)]
143pub enum ModeSource {
144 Default,
146 Project,
148 Personal,
150 Item,
152 ProjectMax,
154}
155
156impl std::fmt::Display for ModeSource {
157 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
158 match self {
159 Self::Default => write!(f, "default"),
160 Self::Project => write!(f, "project"),
161 Self::Personal => write!(f, "personal"),
162 Self::Item => write!(f, "item"),
163 Self::ProjectMax => write!(f, "project max"),
164 }
165 }
166}
167
168pub fn resolve_mode(
177 capability: &Capability,
178 raw_defaults: &ModeDefaults,
179 effective_defaults: &ModeDefaults,
180 personal_mode: Option<InteractionLevel>,
181 member_cap_config: Option<&CapabilityConfig>,
182) -> (InteractionLevel, ModeSource) {
183 let mut mode = effective_defaults.default;
185 let mut source = if effective_defaults.default != raw_defaults.default {
186 ModeSource::Project
187 } else {
188 ModeSource::Default
189 };
190
191 if let Some(&cap_mode) = effective_defaults.capabilities.get(capability) {
193 mode = cap_mode;
194 let from_raw = raw_defaults.capabilities.get(capability) == Some(&cap_mode);
195 source = if from_raw {
196 ModeSource::Default
197 } else {
198 ModeSource::Project
199 };
200 }
201
202 if let Some(personal) = personal_mode {
204 mode = personal;
205 source = ModeSource::Personal;
206 }
207
208 if let Some(cap_config) = member_cap_config {
210 if let Some(max) = cap_config.max_mode {
211 if mode < max {
212 mode = max;
213 source = ModeSource::ProjectMax;
214 }
215 }
216 }
217
218 (mode, source)
219}
220
221impl Serialize for MemberCapabilities {
223 fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
224 match self {
225 MemberCapabilities::All => serializer.serialize_str("all"),
226 MemberCapabilities::Specific(map) => map.serialize(serializer),
227 }
228 }
229}
230
231impl<'de> Deserialize<'de> for MemberCapabilities {
232 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
233 let value = serde_yaml_ng::Value::deserialize(deserializer)?;
234 match &value {
235 serde_yaml_ng::Value::String(s) if s == "all" => Ok(MemberCapabilities::All),
236 serde_yaml_ng::Value::Mapping(_) => {
237 let map: BTreeMap<Capability, CapabilityConfig> =
238 serde_yaml_ng::from_value(value).map_err(serde::de::Error::custom)?;
239 Ok(MemberCapabilities::Specific(map))
240 }
241 _ => Err(serde::de::Error::custom(
242 "expected \"all\" or a map of capabilities",
243 )),
244 }
245 }
246}
247
248impl Member {
249 pub fn new(capabilities: MemberCapabilities) -> Self {
251 Self {
252 capabilities,
253 public_key: None,
254 salt: None,
255 otp_hash: None,
256 ai_delegations: BTreeMap::new(),
257 }
258 }
259
260 pub fn has_capability(&self, cap: &Capability) -> bool {
262 match &self.capabilities {
263 MemberCapabilities::All => true,
264 MemberCapabilities::Specific(map) => map.contains_key(cap),
265 }
266 }
267}
268
269pub fn is_ai_member(id: &str) -> bool {
271 id.starts_with("ai:")
272}
273
274fn default_language() -> String {
275 "en".to_string()
276}
277
278impl Project {
279 pub fn new(name: String, acronym: Option<String>) -> Self {
280 Self {
281 name,
282 acronym,
283 description: None,
284 language: default_language(),
285 forge: None,
286 docs: Docs::default(),
287 members: BTreeMap::new(),
288 created: Utc::now(),
289 }
290 }
291}
292
293pub fn validate_acronym(value: &str) -> Result<String, String> {
300 let normalized = value.trim().to_uppercase();
301 if normalized.len() < 2 || normalized.len() > 8 {
302 return Err(format!(
303 "acronym must be 2-8 characters, got {} ('{}')",
304 normalized.len(),
305 normalized
306 ));
307 }
308 for (i, c) in normalized.chars().enumerate() {
309 if !(c.is_ascii_uppercase() || c.is_ascii_digit()) {
310 return Err(format!(
311 "acronym character '{c}' at position {i} is not A-Z or 0-9"
312 ));
313 }
314 }
315 Ok(normalized)
316}
317
318pub fn derive_acronym(name: &str) -> String {
322 let words: Vec<&str> = name.split_whitespace().collect();
323 if words.len() == 1 {
324 words[0]
325 .chars()
326 .filter(|c| c.is_alphanumeric())
327 .take(3)
328 .collect::<String>()
329 .to_uppercase()
330 } else {
331 words
332 .iter()
333 .filter_map(|w| w.chars().next())
334 .filter(|c| c.is_alphanumeric())
335 .take(4)
336 .collect::<String>()
337 .to_uppercase()
338 }
339}
340
341#[cfg(test)]
342mod tests {
343 use super::*;
344
345 #[test]
346 fn project_roundtrip() {
347 let project = Project::new("Test Project".into(), Some("TP".into()));
348 let yaml = serde_yaml_ng::to_string(&project).unwrap();
349 let parsed: Project = serde_yaml_ng::from_str(&yaml).unwrap();
350 assert_eq!(project, parsed);
351 }
352
353 #[test]
358 fn ai_delegations_omitted_when_empty() {
359 let mut m = Member::new(MemberCapabilities::All);
360 assert!(m.ai_delegations.is_empty());
361 let yaml = serde_yaml_ng::to_string(&m).unwrap();
362 assert!(
363 !yaml.contains("ai_delegations"),
364 "empty ai_delegations should be skipped, got: {yaml}"
365 );
366 m.public_key = Some("aa".repeat(32));
368 let yaml = serde_yaml_ng::to_string(&m).unwrap();
369 let parsed: Member = serde_yaml_ng::from_str(&yaml).unwrap();
370 assert_eq!(m, parsed);
371 }
372
373 #[test]
374 fn ai_delegations_yaml_roundtrip() {
375 let mut m = Member::new(MemberCapabilities::All);
376 m.public_key = Some("aa".repeat(32));
377 m.salt = Some("bb".repeat(32));
378 m.ai_delegations.insert(
379 "ai:claude@joy".into(),
380 AiDelegationEntry {
381 delegation_key: "cc".repeat(32),
382 created: chrono::DateTime::parse_from_rfc3339("2026-04-15T10:00:00Z")
383 .unwrap()
384 .with_timezone(&chrono::Utc),
385 rotated: None,
386 },
387 );
388 let yaml = serde_yaml_ng::to_string(&m).unwrap();
389 assert!(yaml.contains("ai_delegations:"));
390 assert!(yaml.contains("ai:claude@joy:"));
391 assert!(yaml.contains("delegation_key:"));
392 assert!(
393 !yaml.contains("rotated:"),
394 "unset rotated should be skipped"
395 );
396
397 let parsed: Member = serde_yaml_ng::from_str(&yaml).unwrap();
398 assert_eq!(m, parsed);
399 }
400
401 #[test]
402 fn ai_delegations_with_rotated_roundtrips() {
403 let mut m = Member::new(MemberCapabilities::All);
404 let created = chrono::DateTime::parse_from_rfc3339("2026-04-01T10:00:00Z")
405 .unwrap()
406 .with_timezone(&chrono::Utc);
407 let rotated = chrono::DateTime::parse_from_rfc3339("2026-04-15T12:30:00Z")
408 .unwrap()
409 .with_timezone(&chrono::Utc);
410 m.ai_delegations.insert(
411 "ai:claude@joy".into(),
412 AiDelegationEntry {
413 delegation_key: "dd".repeat(32),
414 created,
415 rotated: Some(rotated),
416 },
417 );
418 let yaml = serde_yaml_ng::to_string(&m).unwrap();
419 assert!(yaml.contains("rotated:"));
420 let parsed: Member = serde_yaml_ng::from_str(&yaml).unwrap();
421 assert_eq!(m.ai_delegations["ai:claude@joy"].rotated, Some(rotated));
422 assert_eq!(parsed, m);
423 }
424
425 #[test]
426 fn unknown_fields_from_legacy_yaml_are_ignored() {
427 let yaml = r#"
431capabilities: all
432public_key: aa
433salt: bb
434ai_tokens:
435 ai:claude@joy:
436 token_key: oldkey
437 created: "2026-03-28T22:00:00Z"
438ai_delegations:
439 ai:claude@joy:
440 delegation_key: newkey
441 created: "2026-04-15T10:00:00Z"
442"#;
443 let parsed: Member = serde_yaml_ng::from_str(yaml).unwrap();
444 assert_eq!(
445 parsed.ai_delegations["ai:claude@joy"].delegation_key,
446 "newkey"
447 );
448 }
449
450 #[test]
455 fn docs_defaults_when_unset() {
456 let docs = Docs::default();
457 assert_eq!(docs.architecture_or_default(), Docs::DEFAULT_ARCHITECTURE);
458 assert_eq!(docs.vision_or_default(), Docs::DEFAULT_VISION);
459 assert_eq!(docs.contributing_or_default(), Docs::DEFAULT_CONTRIBUTING);
460 }
461
462 #[test]
463 fn docs_returns_configured_value() {
464 let docs = Docs {
465 architecture: Some("ARCHITECTURE.md".into()),
466 vision: Some("docs/product/vision.md".into()),
467 contributing: None,
468 };
469 assert_eq!(docs.architecture_or_default(), "ARCHITECTURE.md");
470 assert_eq!(docs.vision_or_default(), "docs/product/vision.md");
471 assert_eq!(docs.contributing_or_default(), Docs::DEFAULT_CONTRIBUTING);
472 }
473
474 #[test]
475 fn docs_omitted_from_yaml_when_empty() {
476 let project = Project::new("X".into(), None);
477 let yaml = serde_yaml_ng::to_string(&project).unwrap();
478 assert!(
479 !yaml.contains("docs:"),
480 "empty docs should be skipped, got: {yaml}"
481 );
482 }
483
484 #[test]
485 fn docs_present_in_yaml_when_set() {
486 let mut project = Project::new("X".into(), None);
487 project.docs.architecture = Some("ARCHITECTURE.md".into());
488 let yaml = serde_yaml_ng::to_string(&project).unwrap();
489 assert!(yaml.contains("docs:"), "docs block expected: {yaml}");
490 assert!(yaml.contains("architecture: ARCHITECTURE.md"));
491 assert!(!yaml.contains("vision:"), "unset fields should be skipped");
492 }
493
494 #[test]
495 fn docs_yaml_roundtrip_with_overrides() {
496 let yaml = r#"
497name: Existing
498language: en
499docs:
500 architecture: ARCHITECTURE.md
501 contributing: docs/CONTRIBUTING.md
502created: 2026-01-01T00:00:00Z
503"#;
504 let parsed: Project = serde_yaml_ng::from_str(yaml).unwrap();
505 assert_eq!(parsed.docs.architecture.as_deref(), Some("ARCHITECTURE.md"));
506 assert_eq!(parsed.docs.vision, None);
507 assert_eq!(
508 parsed.docs.contributing.as_deref(),
509 Some("docs/CONTRIBUTING.md")
510 );
511 assert_eq!(parsed.docs.vision_or_default(), Docs::DEFAULT_VISION);
512 }
513
514 #[test]
515 fn derive_acronym_multi_word() {
516 assert_eq!(derive_acronym("My Cool Project"), "MCP");
517 }
518
519 #[test]
520 fn derive_acronym_single_word() {
521 assert_eq!(derive_acronym("Joy"), "JOY");
522 }
523
524 #[test]
525 fn derive_acronym_long_name() {
526 assert_eq!(derive_acronym("A Very Long Project Name"), "AVLP");
527 }
528
529 #[test]
530 fn derive_acronym_single_long_word() {
531 assert_eq!(derive_acronym("Platform"), "PLA");
532 }
533
534 #[test]
539 fn validate_acronym_accepts_real_project_acronyms() {
540 for a in ["JI", "JOT", "JOY", "JON", "JP", "JAPP", "JOYC", "JISITE"] {
541 assert_eq!(validate_acronym(a).unwrap(), a, "rejected real acronym {a}");
542 }
543 }
544
545 #[test]
546 fn validate_acronym_accepts_alphanumeric() {
547 assert_eq!(validate_acronym("V2").unwrap(), "V2");
548 assert_eq!(validate_acronym("A1B2").unwrap(), "A1B2");
549 }
550
551 #[test]
552 fn validate_acronym_normalizes_case_and_whitespace() {
553 assert_eq!(validate_acronym("jyn").unwrap(), "JYN");
554 assert_eq!(validate_acronym("Jyn").unwrap(), "JYN");
555 assert_eq!(validate_acronym(" jyn ").unwrap(), "JYN");
556 }
557
558 #[test]
559 fn validate_acronym_rejects_too_short() {
560 assert!(validate_acronym("").is_err());
561 assert!(validate_acronym("J").is_err());
562 assert!(validate_acronym(" J ").is_err());
563 }
564
565 #[test]
566 fn validate_acronym_rejects_too_long() {
567 assert!(validate_acronym("ABCDEFGHI").is_err());
568 }
569
570 #[test]
571 fn validate_acronym_rejects_non_alnum() {
572 assert!(validate_acronym("JY-N").is_err());
573 assert!(validate_acronym("JY N").is_err());
574 assert!(validate_acronym("JY_N").is_err());
575 assert!(validate_acronym("JY.N").is_err());
576 }
577
578 #[test]
579 fn validate_acronym_rejects_non_ascii() {
580 assert!(validate_acronym("AEBC").is_ok());
581 assert!(validate_acronym("ABC").is_ok());
582 assert!(validate_acronym("\u{00c4}BC").is_err());
583 }
584
585 #[test]
590 fn mode_defaults_flat_yaml_roundtrip() {
591 let yaml = r#"
592default: interactive
593implement: collaborative
594review: pairing
595"#;
596 let parsed: ModeDefaults = serde_yaml_ng::from_str(yaml).unwrap();
597 assert_eq!(parsed.default, InteractionLevel::Interactive);
598 assert_eq!(
599 parsed.capabilities[&Capability::Implement],
600 InteractionLevel::Collaborative
601 );
602 assert_eq!(
603 parsed.capabilities[&Capability::Review],
604 InteractionLevel::Pairing
605 );
606 }
607
608 #[test]
609 fn mode_defaults_empty_yaml() {
610 let yaml = "{}";
611 let parsed: ModeDefaults = serde_yaml_ng::from_str(yaml).unwrap();
612 assert_eq!(parsed.default, InteractionLevel::Collaborative);
613 assert!(parsed.capabilities.is_empty());
614 }
615
616 #[test]
617 fn mode_defaults_only_default() {
618 let yaml = "default: pairing";
619 let parsed: ModeDefaults = serde_yaml_ng::from_str(yaml).unwrap();
620 assert_eq!(parsed.default, InteractionLevel::Pairing);
621 assert!(parsed.capabilities.is_empty());
622 }
623
624 #[test]
625 fn ai_defaults_yaml_roundtrip() {
626 let yaml = r#"
627capabilities:
628 - implement
629 - review
630"#;
631 let parsed: AiDefaults = serde_yaml_ng::from_str(yaml).unwrap();
632 assert_eq!(parsed.capabilities.len(), 2);
633 assert_eq!(parsed.capabilities[0], Capability::Implement);
634 }
635
636 fn defaults_with_mode(mode: InteractionLevel) -> ModeDefaults {
641 ModeDefaults {
642 default: mode,
643 ..Default::default()
644 }
645 }
646
647 fn defaults_with_cap_mode(cap: Capability, mode: InteractionLevel) -> ModeDefaults {
648 let mut d = ModeDefaults::default();
649 d.capabilities.insert(cap, mode);
650 d
651 }
652
653 #[test]
654 fn resolve_mode_uses_global_default() {
655 let raw = defaults_with_mode(InteractionLevel::Collaborative);
656 let effective = raw.clone();
657 let (mode, source) = resolve_mode(&Capability::Implement, &raw, &effective, None, None);
658 assert_eq!(mode, InteractionLevel::Collaborative);
659 assert_eq!(source, ModeSource::Default);
660 }
661
662 #[test]
663 fn resolve_mode_uses_per_capability_default() {
664 let raw = defaults_with_cap_mode(Capability::Review, InteractionLevel::Interactive);
665 let effective = raw.clone();
666 let (mode, source) = resolve_mode(&Capability::Review, &raw, &effective, None, None);
667 assert_eq!(mode, InteractionLevel::Interactive);
668 assert_eq!(source, ModeSource::Default);
669 }
670
671 #[test]
672 fn resolve_mode_project_override_detected() {
673 let raw = defaults_with_cap_mode(Capability::Implement, InteractionLevel::Collaborative);
674 let effective =
675 defaults_with_cap_mode(Capability::Implement, InteractionLevel::Interactive);
676 let (mode, source) = resolve_mode(&Capability::Implement, &raw, &effective, None, None);
677 assert_eq!(mode, InteractionLevel::Interactive);
678 assert_eq!(source, ModeSource::Project);
679 }
680
681 #[test]
682 fn resolve_mode_personal_overrides_default() {
683 let raw = defaults_with_mode(InteractionLevel::Collaborative);
684 let effective = raw.clone();
685 let (mode, source) = resolve_mode(
686 &Capability::Implement,
687 &raw,
688 &effective,
689 Some(InteractionLevel::Pairing),
690 None,
691 );
692 assert_eq!(mode, InteractionLevel::Pairing);
693 assert_eq!(source, ModeSource::Personal);
694 }
695
696 #[test]
697 fn resolve_mode_max_mode_clamps_upward() {
698 let raw = defaults_with_mode(InteractionLevel::Autonomous);
699 let effective = raw.clone();
700 let cap_config = CapabilityConfig {
701 max_mode: Some(InteractionLevel::Supervised),
702 ..Default::default()
703 };
704 let (mode, source) = resolve_mode(
705 &Capability::Implement,
706 &raw,
707 &effective,
708 None,
709 Some(&cap_config),
710 );
711 assert_eq!(mode, InteractionLevel::Supervised);
712 assert_eq!(source, ModeSource::ProjectMax);
713 }
714
715 #[test]
716 fn resolve_mode_max_mode_does_not_lower() {
717 let raw = defaults_with_mode(InteractionLevel::Pairing);
718 let effective = raw.clone();
719 let cap_config = CapabilityConfig {
720 max_mode: Some(InteractionLevel::Supervised),
721 ..Default::default()
722 };
723 let (mode, source) = resolve_mode(
724 &Capability::Implement,
725 &raw,
726 &effective,
727 None,
728 Some(&cap_config),
729 );
730 assert_eq!(mode, InteractionLevel::Pairing);
732 assert_eq!(source, ModeSource::Default);
733 }
734
735 #[test]
736 fn resolve_mode_personal_clamped_by_max() {
737 let raw = defaults_with_mode(InteractionLevel::Collaborative);
738 let effective = raw.clone();
739 let cap_config = CapabilityConfig {
740 max_mode: Some(InteractionLevel::Interactive),
741 ..Default::default()
742 };
743 let (mode, source) = resolve_mode(
744 &Capability::Implement,
745 &raw,
746 &effective,
747 Some(InteractionLevel::Autonomous),
748 Some(&cap_config),
749 );
750 assert_eq!(mode, InteractionLevel::Interactive);
752 assert_eq!(source, ModeSource::ProjectMax);
753 }
754
755 #[test]
760 fn item_mode_field_roundtrip() {
761 use crate::model::item::{Item, ItemType, Priority};
762
763 let mut item = Item::new(
764 "TST-0001".into(),
765 "Test".into(),
766 ItemType::Task,
767 Priority::Medium,
768 vec![],
769 );
770 item.mode = Some(InteractionLevel::Pairing);
771
772 let yaml = serde_yaml_ng::to_string(&item).unwrap();
773 assert!(yaml.contains("mode: pairing"), "mode field not serialized");
774
775 let parsed: Item = serde_yaml_ng::from_str(&yaml).unwrap();
776 assert_eq!(parsed.mode, Some(InteractionLevel::Pairing));
777 }
778
779 #[test]
780 fn item_mode_field_absent_when_none() {
781 use crate::model::item::{Item, ItemType, Priority};
782
783 let item = Item::new(
784 "TST-0002".into(),
785 "Test".into(),
786 ItemType::Task,
787 Priority::Medium,
788 vec![],
789 );
790 assert_eq!(item.mode, None);
791
792 let yaml = serde_yaml_ng::to_string(&item).unwrap();
793 assert!(
794 !yaml.contains("mode:"),
795 "mode field should not appear when None"
796 );
797 }
798
799 #[test]
800 fn item_mode_deserialized_from_existing_yaml() {
801 let yaml = r#"
802id: TST-0003
803title: Test
804type: task
805status: new
806priority: medium
807mode: interactive
808created: "2026-01-01T00:00:00+00:00"
809updated: "2026-01-01T00:00:00+00:00"
810"#;
811 let item: crate::model::item::Item = serde_yaml_ng::from_str(yaml).unwrap();
812 assert_eq!(item.mode, Some(InteractionLevel::Interactive));
813 }
814
815 #[test]
820 fn resolve_mode_full_scenario() {
821 let raw = defaults_with_cap_mode(Capability::Implement, InteractionLevel::Collaborative);
823 let effective =
825 defaults_with_cap_mode(Capability::Implement, InteractionLevel::Interactive);
826 let personal = Some(InteractionLevel::Autonomous);
828 let cap_config = CapabilityConfig {
830 max_mode: Some(InteractionLevel::Supervised),
831 ..Default::default()
832 };
833
834 let (mode, source) = resolve_mode(
835 &Capability::Implement,
836 &raw,
837 &effective,
838 personal,
839 Some(&cap_config),
840 );
841
842 assert_eq!(mode, InteractionLevel::Supervised);
844 assert_eq!(source, ModeSource::ProjectMax);
845 }
846
847 #[test]
848 fn resolve_mode_all_layers_no_clamping() {
849 let raw = defaults_with_cap_mode(Capability::Implement, InteractionLevel::Collaborative);
851 let effective =
853 defaults_with_cap_mode(Capability::Implement, InteractionLevel::Interactive);
854 let personal = Some(InteractionLevel::Pairing);
856 let cap_config = CapabilityConfig::default();
858
859 let (mode, source) = resolve_mode(
860 &Capability::Implement,
861 &raw,
862 &effective,
863 personal,
864 Some(&cap_config),
865 );
866
867 assert_eq!(mode, InteractionLevel::Pairing);
869 assert_eq!(source, ModeSource::Personal);
870 }
871}