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/// Validate and normalize a project acronym.
294///
295/// Acronyms drive item ID prefixes (`ACRONYM-XXXX`) and must therefore be
296/// ASCII, filesystem-safe, and short. Rules: ASCII uppercase letters (A-Z) or
297/// digits (0-9), length 2-8 after trimming. Input is trimmed and uppercased;
298/// the normalized form is returned on success so callers can store it as-is.
299pub 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
318/// Derive an acronym from a project name.
319/// Takes the first letter of each word, uppercase, max 4 characters.
320/// Single words use up to 3 uppercase characters.
321pub 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    // -----------------------------------------------------------------------
354    // ai_delegations (ADR-033) tests
355    // -----------------------------------------------------------------------
356
357    #[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        // sanity: round-trips empty
367        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        // project.yaml files written by older Joy versions may still carry
428        // ai_tokens entries. They are silently discarded by serde default
429        // behaviour and do not block deserialisation.
430        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    // -----------------------------------------------------------------------
451    // Docs tests
452    // -----------------------------------------------------------------------
453
454    #[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    // -----------------------------------------------------------------------
535    // validate_acronym tests
536    // -----------------------------------------------------------------------
537
538    #[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    // -----------------------------------------------------------------------
586    // ModeDefaults deserialization tests
587    // -----------------------------------------------------------------------
588
589    #[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    // -----------------------------------------------------------------------
637    // resolve_mode tests
638    // -----------------------------------------------------------------------
639
640    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        // Pairing > Supervised, so no clamping
731        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        // Personal is Autonomous but max is Interactive, clamp up
751        assert_eq!(mode, InteractionLevel::Interactive);
752        assert_eq!(source, ModeSource::ProjectMax);
753    }
754
755    // -----------------------------------------------------------------------
756    // Item mode serialization
757    // -----------------------------------------------------------------------
758
759    #[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    // -----------------------------------------------------------------------
816    // Full four-layer resolution scenario
817    // -----------------------------------------------------------------------
818
819    #[test]
820    fn resolve_mode_full_scenario() {
821        // Joy default: implement = collaborative
822        let raw = defaults_with_cap_mode(Capability::Implement, InteractionLevel::Collaborative);
823        // Project override: implement = interactive
824        let effective =
825            defaults_with_cap_mode(Capability::Implement, InteractionLevel::Interactive);
826        // Personal preference: autonomous
827        let personal = Some(InteractionLevel::Autonomous);
828        // Project max-mode: supervised (minimum interactivity)
829        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        // Personal (autonomous) < max (supervised), so clamped up to supervised
843        assert_eq!(mode, InteractionLevel::Supervised);
844        assert_eq!(source, ModeSource::ProjectMax);
845    }
846
847    #[test]
848    fn resolve_mode_all_layers_no_clamping() {
849        // Joy default: implement = collaborative
850        let raw = defaults_with_cap_mode(Capability::Implement, InteractionLevel::Collaborative);
851        // Project override: implement = interactive
852        let effective =
853            defaults_with_cap_mode(Capability::Implement, InteractionLevel::Interactive);
854        // Personal preference: pairing (more interactive than project)
855        let personal = Some(InteractionLevel::Pairing);
856        // No max-mode
857        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        // Personal wins, no clamping
868        assert_eq!(mode, InteractionLevel::Pairing);
869        assert_eq!(source, ModeSource::Personal);
870    }
871}