Skip to main content

joy_core/model/
project.rs

1// Copyright (c) 2026 Joydev GmbH (joydev.com)
2// SPDX-License-Identifier: MIT
3
4use 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/// Configurable paths to the project's reference documentation, relative to
31/// the project root. Used by `joy ai init` to support existing repos with
32/// non-default doc layouts and read by AI tools via `joy project get docs.<key>`.
33#[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    /// Configured architecture path or the default if unset.
53    pub fn architecture_or_default(&self) -> &str {
54        self.architecture
55            .as_deref()
56            .unwrap_or(Self::DEFAULT_ARCHITECTURE)
57    }
58
59    /// Configured vision path or the default if unset.
60    pub fn vision_or_default(&self) -> &str {
61        self.vision.as_deref().unwrap_or(Self::DEFAULT_VISION)
62    }
63
64    /// Configured contributing path or the default if unset.
65    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/// A stable per-(human, AI) delegation key (ADR-033). The matching private
86/// key lives off-repo at
87/// `~/.local/state/joy/delegations/<project>/<ai-member>.key`.
88#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
89pub struct AiDelegationEntry {
90    /// Public key of the stable delegation keypair (hex-encoded Ed25519).
91    pub delegation_key: String,
92    /// When this delegation was first issued.
93    pub created: chrono::DateTime<chrono::Utc>,
94    /// When this delegation was last rotated, if ever.
95    #[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// ---------------------------------------------------------------------------
118// Mode defaults (from project.defaults.yaml, overridable in project.yaml)
119// ---------------------------------------------------------------------------
120
121/// Interaction mode defaults: a global default plus optional per-capability overrides.
122/// Deserializes from flat YAML like: `{ default: collaborative, implement: autonomous }`.
123#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
124pub struct ModeDefaults {
125    /// Fallback mode when no per-capability mode is set.
126    #[serde(default)]
127    pub default: InteractionLevel,
128    /// Per-capability mode overrides (flattened into the same map).
129    #[serde(flatten, default)]
130    pub capabilities: BTreeMap<Capability, InteractionLevel>,
131}
132
133/// Default capabilities granted to AI members by joy ai init.
134/// Loaded from `ai-defaults.capabilities` in project.defaults.yaml.
135#[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/// Source of a resolved interaction mode.
142#[derive(Debug, Clone, Copy, PartialEq, Eq)]
143pub enum ModeSource {
144    /// From project.defaults.yaml (Joy's recommendation).
145    Default,
146    /// From project.yaml agents.defaults override.
147    Project,
148    /// From config.yaml personal preference.
149    Personal,
150    /// From item-level override (future).
151    Item,
152    /// Clamped by max-mode from project.yaml member config.
153    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
168/// Resolve the effective interaction mode for a given capability.
169///
170/// Resolution order (later wins):
171/// 1. Effective defaults global mode (project.defaults.yaml merged with project.yaml)
172/// 2. Effective defaults per-capability mode
173/// 3. Personal config preference
174///
175/// All clamped by max-mode from the member's CapabilityConfig.
176pub 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    // 1. Global fallback from effective defaults
184    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    // 2. Per-capability default
192    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    // 3. Personal preference
203    if let Some(personal) = personal_mode {
204        mode = personal;
205        source = ModeSource::Personal;
206    }
207
208    // 4. Clamp by max-mode (minimum interactivity required)
209    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
221// Custom serde for MemberCapabilities: "all" string or map of capabilities
222impl 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    /// Create a member with the given capabilities and no auth fields.
250    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    /// Check whether this member has a specific capability.
261    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
269/// Check whether a member ID represents an AI member.
270pub 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
293/// Derive an acronym from a project name.
294/// Takes the first letter of each word, uppercase, max 4 characters.
295/// Single words use up to 3 uppercase characters.
296pub fn derive_acronym(name: &str) -> String {
297    let words: Vec<&str> = name.split_whitespace().collect();
298    if words.len() == 1 {
299        words[0]
300            .chars()
301            .filter(|c| c.is_alphanumeric())
302            .take(3)
303            .collect::<String>()
304            .to_uppercase()
305    } else {
306        words
307            .iter()
308            .filter_map(|w| w.chars().next())
309            .filter(|c| c.is_alphanumeric())
310            .take(4)
311            .collect::<String>()
312            .to_uppercase()
313    }
314}
315
316#[cfg(test)]
317mod tests {
318    use super::*;
319
320    #[test]
321    fn project_roundtrip() {
322        let project = Project::new("Test Project".into(), Some("TP".into()));
323        let yaml = serde_yaml_ng::to_string(&project).unwrap();
324        let parsed: Project = serde_yaml_ng::from_str(&yaml).unwrap();
325        assert_eq!(project, parsed);
326    }
327
328    // -----------------------------------------------------------------------
329    // ai_delegations (ADR-033) tests
330    // -----------------------------------------------------------------------
331
332    #[test]
333    fn ai_delegations_omitted_when_empty() {
334        let mut m = Member::new(MemberCapabilities::All);
335        assert!(m.ai_delegations.is_empty());
336        let yaml = serde_yaml_ng::to_string(&m).unwrap();
337        assert!(
338            !yaml.contains("ai_delegations"),
339            "empty ai_delegations should be skipped, got: {yaml}"
340        );
341        // sanity: round-trips empty
342        m.public_key = Some("aa".repeat(32));
343        let yaml = serde_yaml_ng::to_string(&m).unwrap();
344        let parsed: Member = serde_yaml_ng::from_str(&yaml).unwrap();
345        assert_eq!(m, parsed);
346    }
347
348    #[test]
349    fn ai_delegations_yaml_roundtrip() {
350        let mut m = Member::new(MemberCapabilities::All);
351        m.public_key = Some("aa".repeat(32));
352        m.salt = Some("bb".repeat(32));
353        m.ai_delegations.insert(
354            "ai:claude@joy".into(),
355            AiDelegationEntry {
356                delegation_key: "cc".repeat(32),
357                created: chrono::DateTime::parse_from_rfc3339("2026-04-15T10:00:00Z")
358                    .unwrap()
359                    .with_timezone(&chrono::Utc),
360                rotated: None,
361            },
362        );
363        let yaml = serde_yaml_ng::to_string(&m).unwrap();
364        assert!(yaml.contains("ai_delegations:"));
365        assert!(yaml.contains("ai:claude@joy:"));
366        assert!(yaml.contains("delegation_key:"));
367        assert!(
368            !yaml.contains("rotated:"),
369            "unset rotated should be skipped"
370        );
371
372        let parsed: Member = serde_yaml_ng::from_str(&yaml).unwrap();
373        assert_eq!(m, parsed);
374    }
375
376    #[test]
377    fn ai_delegations_with_rotated_roundtrips() {
378        let mut m = Member::new(MemberCapabilities::All);
379        let created = chrono::DateTime::parse_from_rfc3339("2026-04-01T10:00:00Z")
380            .unwrap()
381            .with_timezone(&chrono::Utc);
382        let rotated = chrono::DateTime::parse_from_rfc3339("2026-04-15T12:30:00Z")
383            .unwrap()
384            .with_timezone(&chrono::Utc);
385        m.ai_delegations.insert(
386            "ai:claude@joy".into(),
387            AiDelegationEntry {
388                delegation_key: "dd".repeat(32),
389                created,
390                rotated: Some(rotated),
391            },
392        );
393        let yaml = serde_yaml_ng::to_string(&m).unwrap();
394        assert!(yaml.contains("rotated:"));
395        let parsed: Member = serde_yaml_ng::from_str(&yaml).unwrap();
396        assert_eq!(m.ai_delegations["ai:claude@joy"].rotated, Some(rotated));
397        assert_eq!(parsed, m);
398    }
399
400    #[test]
401    fn unknown_fields_from_legacy_yaml_are_ignored() {
402        // project.yaml files written by older Joy versions may still carry
403        // ai_tokens entries. They are silently discarded by serde default
404        // behaviour and do not block deserialisation.
405        let yaml = r#"
406capabilities: all
407public_key: aa
408salt: bb
409ai_tokens:
410  ai:claude@joy:
411    token_key: oldkey
412    created: "2026-03-28T22:00:00Z"
413ai_delegations:
414  ai:claude@joy:
415    delegation_key: newkey
416    created: "2026-04-15T10:00:00Z"
417"#;
418        let parsed: Member = serde_yaml_ng::from_str(yaml).unwrap();
419        assert_eq!(
420            parsed.ai_delegations["ai:claude@joy"].delegation_key,
421            "newkey"
422        );
423    }
424
425    // -----------------------------------------------------------------------
426    // Docs tests
427    // -----------------------------------------------------------------------
428
429    #[test]
430    fn docs_defaults_when_unset() {
431        let docs = Docs::default();
432        assert_eq!(docs.architecture_or_default(), Docs::DEFAULT_ARCHITECTURE);
433        assert_eq!(docs.vision_or_default(), Docs::DEFAULT_VISION);
434        assert_eq!(docs.contributing_or_default(), Docs::DEFAULT_CONTRIBUTING);
435    }
436
437    #[test]
438    fn docs_returns_configured_value() {
439        let docs = Docs {
440            architecture: Some("ARCHITECTURE.md".into()),
441            vision: Some("docs/product/vision.md".into()),
442            contributing: None,
443        };
444        assert_eq!(docs.architecture_or_default(), "ARCHITECTURE.md");
445        assert_eq!(docs.vision_or_default(), "docs/product/vision.md");
446        assert_eq!(docs.contributing_or_default(), Docs::DEFAULT_CONTRIBUTING);
447    }
448
449    #[test]
450    fn docs_omitted_from_yaml_when_empty() {
451        let project = Project::new("X".into(), None);
452        let yaml = serde_yaml_ng::to_string(&project).unwrap();
453        assert!(
454            !yaml.contains("docs:"),
455            "empty docs should be skipped, got: {yaml}"
456        );
457    }
458
459    #[test]
460    fn docs_present_in_yaml_when_set() {
461        let mut project = Project::new("X".into(), None);
462        project.docs.architecture = Some("ARCHITECTURE.md".into());
463        let yaml = serde_yaml_ng::to_string(&project).unwrap();
464        assert!(yaml.contains("docs:"), "docs block expected: {yaml}");
465        assert!(yaml.contains("architecture: ARCHITECTURE.md"));
466        assert!(!yaml.contains("vision:"), "unset fields should be skipped");
467    }
468
469    #[test]
470    fn docs_yaml_roundtrip_with_overrides() {
471        let yaml = r#"
472name: Existing
473language: en
474docs:
475  architecture: ARCHITECTURE.md
476  contributing: docs/CONTRIBUTING.md
477created: 2026-01-01T00:00:00Z
478"#;
479        let parsed: Project = serde_yaml_ng::from_str(yaml).unwrap();
480        assert_eq!(parsed.docs.architecture.as_deref(), Some("ARCHITECTURE.md"));
481        assert_eq!(parsed.docs.vision, None);
482        assert_eq!(
483            parsed.docs.contributing.as_deref(),
484            Some("docs/CONTRIBUTING.md")
485        );
486        assert_eq!(parsed.docs.vision_or_default(), Docs::DEFAULT_VISION);
487    }
488
489    #[test]
490    fn derive_acronym_multi_word() {
491        assert_eq!(derive_acronym("My Cool Project"), "MCP");
492    }
493
494    #[test]
495    fn derive_acronym_single_word() {
496        assert_eq!(derive_acronym("Joy"), "JOY");
497    }
498
499    #[test]
500    fn derive_acronym_long_name() {
501        assert_eq!(derive_acronym("A Very Long Project Name"), "AVLP");
502    }
503
504    #[test]
505    fn derive_acronym_single_long_word() {
506        assert_eq!(derive_acronym("Platform"), "PLA");
507    }
508
509    // -----------------------------------------------------------------------
510    // ModeDefaults deserialization tests
511    // -----------------------------------------------------------------------
512
513    #[test]
514    fn mode_defaults_flat_yaml_roundtrip() {
515        let yaml = r#"
516default: interactive
517implement: collaborative
518review: pairing
519"#;
520        let parsed: ModeDefaults = serde_yaml_ng::from_str(yaml).unwrap();
521        assert_eq!(parsed.default, InteractionLevel::Interactive);
522        assert_eq!(
523            parsed.capabilities[&Capability::Implement],
524            InteractionLevel::Collaborative
525        );
526        assert_eq!(
527            parsed.capabilities[&Capability::Review],
528            InteractionLevel::Pairing
529        );
530    }
531
532    #[test]
533    fn mode_defaults_empty_yaml() {
534        let yaml = "{}";
535        let parsed: ModeDefaults = serde_yaml_ng::from_str(yaml).unwrap();
536        assert_eq!(parsed.default, InteractionLevel::Collaborative);
537        assert!(parsed.capabilities.is_empty());
538    }
539
540    #[test]
541    fn mode_defaults_only_default() {
542        let yaml = "default: pairing";
543        let parsed: ModeDefaults = serde_yaml_ng::from_str(yaml).unwrap();
544        assert_eq!(parsed.default, InteractionLevel::Pairing);
545        assert!(parsed.capabilities.is_empty());
546    }
547
548    #[test]
549    fn ai_defaults_yaml_roundtrip() {
550        let yaml = r#"
551capabilities:
552  - implement
553  - review
554"#;
555        let parsed: AiDefaults = serde_yaml_ng::from_str(yaml).unwrap();
556        assert_eq!(parsed.capabilities.len(), 2);
557        assert_eq!(parsed.capabilities[0], Capability::Implement);
558    }
559
560    // -----------------------------------------------------------------------
561    // resolve_mode tests
562    // -----------------------------------------------------------------------
563
564    fn defaults_with_mode(mode: InteractionLevel) -> ModeDefaults {
565        ModeDefaults {
566            default: mode,
567            ..Default::default()
568        }
569    }
570
571    fn defaults_with_cap_mode(cap: Capability, mode: InteractionLevel) -> ModeDefaults {
572        let mut d = ModeDefaults::default();
573        d.capabilities.insert(cap, mode);
574        d
575    }
576
577    #[test]
578    fn resolve_mode_uses_global_default() {
579        let raw = defaults_with_mode(InteractionLevel::Collaborative);
580        let effective = raw.clone();
581        let (mode, source) = resolve_mode(&Capability::Implement, &raw, &effective, None, None);
582        assert_eq!(mode, InteractionLevel::Collaborative);
583        assert_eq!(source, ModeSource::Default);
584    }
585
586    #[test]
587    fn resolve_mode_uses_per_capability_default() {
588        let raw = defaults_with_cap_mode(Capability::Review, InteractionLevel::Interactive);
589        let effective = raw.clone();
590        let (mode, source) = resolve_mode(&Capability::Review, &raw, &effective, None, None);
591        assert_eq!(mode, InteractionLevel::Interactive);
592        assert_eq!(source, ModeSource::Default);
593    }
594
595    #[test]
596    fn resolve_mode_project_override_detected() {
597        let raw = defaults_with_cap_mode(Capability::Implement, InteractionLevel::Collaborative);
598        let effective =
599            defaults_with_cap_mode(Capability::Implement, InteractionLevel::Interactive);
600        let (mode, source) = resolve_mode(&Capability::Implement, &raw, &effective, None, None);
601        assert_eq!(mode, InteractionLevel::Interactive);
602        assert_eq!(source, ModeSource::Project);
603    }
604
605    #[test]
606    fn resolve_mode_personal_overrides_default() {
607        let raw = defaults_with_mode(InteractionLevel::Collaborative);
608        let effective = raw.clone();
609        let (mode, source) = resolve_mode(
610            &Capability::Implement,
611            &raw,
612            &effective,
613            Some(InteractionLevel::Pairing),
614            None,
615        );
616        assert_eq!(mode, InteractionLevel::Pairing);
617        assert_eq!(source, ModeSource::Personal);
618    }
619
620    #[test]
621    fn resolve_mode_max_mode_clamps_upward() {
622        let raw = defaults_with_mode(InteractionLevel::Autonomous);
623        let effective = raw.clone();
624        let cap_config = CapabilityConfig {
625            max_mode: Some(InteractionLevel::Supervised),
626            ..Default::default()
627        };
628        let (mode, source) = resolve_mode(
629            &Capability::Implement,
630            &raw,
631            &effective,
632            None,
633            Some(&cap_config),
634        );
635        assert_eq!(mode, InteractionLevel::Supervised);
636        assert_eq!(source, ModeSource::ProjectMax);
637    }
638
639    #[test]
640    fn resolve_mode_max_mode_does_not_lower() {
641        let raw = defaults_with_mode(InteractionLevel::Pairing);
642        let effective = raw.clone();
643        let cap_config = CapabilityConfig {
644            max_mode: Some(InteractionLevel::Supervised),
645            ..Default::default()
646        };
647        let (mode, source) = resolve_mode(
648            &Capability::Implement,
649            &raw,
650            &effective,
651            None,
652            Some(&cap_config),
653        );
654        // Pairing > Supervised, so no clamping
655        assert_eq!(mode, InteractionLevel::Pairing);
656        assert_eq!(source, ModeSource::Default);
657    }
658
659    #[test]
660    fn resolve_mode_personal_clamped_by_max() {
661        let raw = defaults_with_mode(InteractionLevel::Collaborative);
662        let effective = raw.clone();
663        let cap_config = CapabilityConfig {
664            max_mode: Some(InteractionLevel::Interactive),
665            ..Default::default()
666        };
667        let (mode, source) = resolve_mode(
668            &Capability::Implement,
669            &raw,
670            &effective,
671            Some(InteractionLevel::Autonomous),
672            Some(&cap_config),
673        );
674        // Personal is Autonomous but max is Interactive, clamp up
675        assert_eq!(mode, InteractionLevel::Interactive);
676        assert_eq!(source, ModeSource::ProjectMax);
677    }
678
679    // -----------------------------------------------------------------------
680    // Item mode serialization
681    // -----------------------------------------------------------------------
682
683    #[test]
684    fn item_mode_field_roundtrip() {
685        use crate::model::item::{Item, ItemType, Priority};
686
687        let mut item = Item::new(
688            "TST-0001".into(),
689            "Test".into(),
690            ItemType::Task,
691            Priority::Medium,
692            vec![],
693        );
694        item.mode = Some(InteractionLevel::Pairing);
695
696        let yaml = serde_yaml_ng::to_string(&item).unwrap();
697        assert!(yaml.contains("mode: pairing"), "mode field not serialized");
698
699        let parsed: Item = serde_yaml_ng::from_str(&yaml).unwrap();
700        assert_eq!(parsed.mode, Some(InteractionLevel::Pairing));
701    }
702
703    #[test]
704    fn item_mode_field_absent_when_none() {
705        use crate::model::item::{Item, ItemType, Priority};
706
707        let item = Item::new(
708            "TST-0002".into(),
709            "Test".into(),
710            ItemType::Task,
711            Priority::Medium,
712            vec![],
713        );
714        assert_eq!(item.mode, None);
715
716        let yaml = serde_yaml_ng::to_string(&item).unwrap();
717        assert!(
718            !yaml.contains("mode:"),
719            "mode field should not appear when None"
720        );
721    }
722
723    #[test]
724    fn item_mode_deserialized_from_existing_yaml() {
725        let yaml = r#"
726id: TST-0003
727title: Test
728type: task
729status: new
730priority: medium
731mode: interactive
732created: "2026-01-01T00:00:00+00:00"
733updated: "2026-01-01T00:00:00+00:00"
734"#;
735        let item: crate::model::item::Item = serde_yaml_ng::from_str(yaml).unwrap();
736        assert_eq!(item.mode, Some(InteractionLevel::Interactive));
737    }
738
739    // -----------------------------------------------------------------------
740    // Full four-layer resolution scenario
741    // -----------------------------------------------------------------------
742
743    #[test]
744    fn resolve_mode_full_scenario() {
745        // Joy default: implement = collaborative
746        let raw = defaults_with_cap_mode(Capability::Implement, InteractionLevel::Collaborative);
747        // Project override: implement = interactive
748        let effective =
749            defaults_with_cap_mode(Capability::Implement, InteractionLevel::Interactive);
750        // Personal preference: autonomous
751        let personal = Some(InteractionLevel::Autonomous);
752        // Project max-mode: supervised (minimum interactivity)
753        let cap_config = CapabilityConfig {
754            max_mode: Some(InteractionLevel::Supervised),
755            ..Default::default()
756        };
757
758        let (mode, source) = resolve_mode(
759            &Capability::Implement,
760            &raw,
761            &effective,
762            personal,
763            Some(&cap_config),
764        );
765
766        // Personal (autonomous) < max (supervised), so clamped up to supervised
767        assert_eq!(mode, InteractionLevel::Supervised);
768        assert_eq!(source, ModeSource::ProjectMax);
769    }
770
771    #[test]
772    fn resolve_mode_all_layers_no_clamping() {
773        // Joy default: implement = collaborative
774        let raw = defaults_with_cap_mode(Capability::Implement, InteractionLevel::Collaborative);
775        // Project override: implement = interactive
776        let effective =
777            defaults_with_cap_mode(Capability::Implement, InteractionLevel::Interactive);
778        // Personal preference: pairing (more interactive than project)
779        let personal = Some(InteractionLevel::Pairing);
780        // No max-mode
781        let cap_config = CapabilityConfig::default();
782
783        let (mode, source) = resolve_mode(
784            &Capability::Implement,
785            &raw,
786            &effective,
787            personal,
788            Some(&cap_config),
789        );
790
791        // Personal wins, no clamping
792        assert_eq!(mode, InteractionLevel::Pairing);
793        assert_eq!(source, ModeSource::Personal);
794    }
795}