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_tokens: BTreeMap<String, AiTokenEntry>,
83}
84
85/// A registered AI delegation token entry.
86#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
87pub struct AiTokenEntry {
88    /// Public key of the one-time token keypair (hex-encoded Ed25519).
89    pub token_key: String,
90    /// When this token was created.
91    pub created: chrono::DateTime<chrono::Utc>,
92}
93
94#[derive(Debug, Clone, PartialEq)]
95pub enum MemberCapabilities {
96    All,
97    Specific(BTreeMap<Capability, CapabilityConfig>),
98}
99
100#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
101pub struct CapabilityConfig {
102    #[serde(rename = "max-mode", default, skip_serializing_if = "Option::is_none")]
103    pub max_mode: Option<InteractionLevel>,
104    #[serde(
105        rename = "max-cost-per-job",
106        default,
107        skip_serializing_if = "Option::is_none"
108    )]
109    pub max_cost_per_job: Option<f64>,
110}
111
112// ---------------------------------------------------------------------------
113// Mode defaults (from project.defaults.yaml, overridable in project.yaml)
114// ---------------------------------------------------------------------------
115
116/// Interaction mode defaults: a global default plus optional per-capability overrides.
117/// Deserializes from flat YAML like: `{ default: collaborative, implement: autonomous }`.
118#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
119pub struct ModeDefaults {
120    /// Fallback mode when no per-capability mode is set.
121    #[serde(default)]
122    pub default: InteractionLevel,
123    /// Per-capability mode overrides (flattened into the same map).
124    #[serde(flatten, default)]
125    pub capabilities: BTreeMap<Capability, InteractionLevel>,
126}
127
128/// Default capabilities granted to AI members by joy ai init.
129/// Loaded from `ai-defaults.capabilities` in project.defaults.yaml.
130#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
131pub struct AiDefaults {
132    #[serde(default, skip_serializing_if = "Vec::is_empty")]
133    pub capabilities: Vec<Capability>,
134}
135
136/// Source of a resolved interaction mode.
137#[derive(Debug, Clone, Copy, PartialEq, Eq)]
138pub enum ModeSource {
139    /// From project.defaults.yaml (Joy's recommendation).
140    Default,
141    /// From project.yaml agents.defaults override.
142    Project,
143    /// From config.yaml personal preference.
144    Personal,
145    /// From item-level override (future).
146    Item,
147    /// Clamped by max-mode from project.yaml member config.
148    ProjectMax,
149}
150
151impl std::fmt::Display for ModeSource {
152    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
153        match self {
154            Self::Default => write!(f, "default"),
155            Self::Project => write!(f, "project"),
156            Self::Personal => write!(f, "personal"),
157            Self::Item => write!(f, "item"),
158            Self::ProjectMax => write!(f, "project max"),
159        }
160    }
161}
162
163/// Resolve the effective interaction mode for a given capability.
164///
165/// Resolution order (later wins):
166/// 1. Effective defaults global mode (project.defaults.yaml merged with project.yaml)
167/// 2. Effective defaults per-capability mode
168/// 3. Personal config preference
169///
170/// All clamped by max-mode from the member's CapabilityConfig.
171pub fn resolve_mode(
172    capability: &Capability,
173    raw_defaults: &ModeDefaults,
174    effective_defaults: &ModeDefaults,
175    personal_mode: Option<InteractionLevel>,
176    member_cap_config: Option<&CapabilityConfig>,
177) -> (InteractionLevel, ModeSource) {
178    // 1. Global fallback from effective defaults
179    let mut mode = effective_defaults.default;
180    let mut source = if effective_defaults.default != raw_defaults.default {
181        ModeSource::Project
182    } else {
183        ModeSource::Default
184    };
185
186    // 2. Per-capability default
187    if let Some(&cap_mode) = effective_defaults.capabilities.get(capability) {
188        mode = cap_mode;
189        let from_raw = raw_defaults.capabilities.get(capability) == Some(&cap_mode);
190        source = if from_raw {
191            ModeSource::Default
192        } else {
193            ModeSource::Project
194        };
195    }
196
197    // 3. Personal preference
198    if let Some(personal) = personal_mode {
199        mode = personal;
200        source = ModeSource::Personal;
201    }
202
203    // 4. Clamp by max-mode (minimum interactivity required)
204    if let Some(cap_config) = member_cap_config {
205        if let Some(max) = cap_config.max_mode {
206            if mode < max {
207                mode = max;
208                source = ModeSource::ProjectMax;
209            }
210        }
211    }
212
213    (mode, source)
214}
215
216// Custom serde for MemberCapabilities: "all" string or map of capabilities
217impl Serialize for MemberCapabilities {
218    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
219        match self {
220            MemberCapabilities::All => serializer.serialize_str("all"),
221            MemberCapabilities::Specific(map) => map.serialize(serializer),
222        }
223    }
224}
225
226impl<'de> Deserialize<'de> for MemberCapabilities {
227    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
228        let value = serde_yaml_ng::Value::deserialize(deserializer)?;
229        match &value {
230            serde_yaml_ng::Value::String(s) if s == "all" => Ok(MemberCapabilities::All),
231            serde_yaml_ng::Value::Mapping(_) => {
232                let map: BTreeMap<Capability, CapabilityConfig> =
233                    serde_yaml_ng::from_value(value).map_err(serde::de::Error::custom)?;
234                Ok(MemberCapabilities::Specific(map))
235            }
236            _ => Err(serde::de::Error::custom(
237                "expected \"all\" or a map of capabilities",
238            )),
239        }
240    }
241}
242
243impl Member {
244    /// Create a member with the given capabilities and no auth fields.
245    pub fn new(capabilities: MemberCapabilities) -> Self {
246        Self {
247            capabilities,
248            public_key: None,
249            salt: None,
250            otp_hash: None,
251            ai_tokens: BTreeMap::new(),
252        }
253    }
254
255    /// Check whether this member has a specific capability.
256    pub fn has_capability(&self, cap: &Capability) -> bool {
257        match &self.capabilities {
258            MemberCapabilities::All => true,
259            MemberCapabilities::Specific(map) => map.contains_key(cap),
260        }
261    }
262}
263
264/// Check whether a member ID represents an AI member.
265pub fn is_ai_member(id: &str) -> bool {
266    id.starts_with("ai:")
267}
268
269fn default_language() -> String {
270    "en".to_string()
271}
272
273impl Project {
274    pub fn new(name: String, acronym: Option<String>) -> Self {
275        Self {
276            name,
277            acronym,
278            description: None,
279            language: default_language(),
280            forge: None,
281            docs: Docs::default(),
282            members: BTreeMap::new(),
283            created: Utc::now(),
284        }
285    }
286}
287
288/// Derive an acronym from a project name.
289/// Takes the first letter of each word, uppercase, max 4 characters.
290/// Single words use up to 3 uppercase characters.
291pub fn derive_acronym(name: &str) -> String {
292    let words: Vec<&str> = name.split_whitespace().collect();
293    if words.len() == 1 {
294        words[0]
295            .chars()
296            .filter(|c| c.is_alphanumeric())
297            .take(3)
298            .collect::<String>()
299            .to_uppercase()
300    } else {
301        words
302            .iter()
303            .filter_map(|w| w.chars().next())
304            .filter(|c| c.is_alphanumeric())
305            .take(4)
306            .collect::<String>()
307            .to_uppercase()
308    }
309}
310
311#[cfg(test)]
312mod tests {
313    use super::*;
314
315    #[test]
316    fn project_roundtrip() {
317        let project = Project::new("Test Project".into(), Some("TP".into()));
318        let yaml = serde_yaml_ng::to_string(&project).unwrap();
319        let parsed: Project = serde_yaml_ng::from_str(&yaml).unwrap();
320        assert_eq!(project, parsed);
321    }
322
323    // -----------------------------------------------------------------------
324    // Docs tests
325    // -----------------------------------------------------------------------
326
327    #[test]
328    fn docs_defaults_when_unset() {
329        let docs = Docs::default();
330        assert_eq!(docs.architecture_or_default(), Docs::DEFAULT_ARCHITECTURE);
331        assert_eq!(docs.vision_or_default(), Docs::DEFAULT_VISION);
332        assert_eq!(docs.contributing_or_default(), Docs::DEFAULT_CONTRIBUTING);
333    }
334
335    #[test]
336    fn docs_returns_configured_value() {
337        let docs = Docs {
338            architecture: Some("ARCHITECTURE.md".into()),
339            vision: Some("docs/product/vision.md".into()),
340            contributing: None,
341        };
342        assert_eq!(docs.architecture_or_default(), "ARCHITECTURE.md");
343        assert_eq!(docs.vision_or_default(), "docs/product/vision.md");
344        assert_eq!(docs.contributing_or_default(), Docs::DEFAULT_CONTRIBUTING);
345    }
346
347    #[test]
348    fn docs_omitted_from_yaml_when_empty() {
349        let project = Project::new("X".into(), None);
350        let yaml = serde_yaml_ng::to_string(&project).unwrap();
351        assert!(
352            !yaml.contains("docs:"),
353            "empty docs should be skipped, got: {yaml}"
354        );
355    }
356
357    #[test]
358    fn docs_present_in_yaml_when_set() {
359        let mut project = Project::new("X".into(), None);
360        project.docs.architecture = Some("ARCHITECTURE.md".into());
361        let yaml = serde_yaml_ng::to_string(&project).unwrap();
362        assert!(yaml.contains("docs:"), "docs block expected: {yaml}");
363        assert!(yaml.contains("architecture: ARCHITECTURE.md"));
364        assert!(!yaml.contains("vision:"), "unset fields should be skipped");
365    }
366
367    #[test]
368    fn docs_yaml_roundtrip_with_overrides() {
369        let yaml = r#"
370name: Existing
371language: en
372docs:
373  architecture: ARCHITECTURE.md
374  contributing: docs/CONTRIBUTING.md
375created: 2026-01-01T00:00:00Z
376"#;
377        let parsed: Project = serde_yaml_ng::from_str(yaml).unwrap();
378        assert_eq!(parsed.docs.architecture.as_deref(), Some("ARCHITECTURE.md"));
379        assert_eq!(parsed.docs.vision, None);
380        assert_eq!(
381            parsed.docs.contributing.as_deref(),
382            Some("docs/CONTRIBUTING.md")
383        );
384        assert_eq!(parsed.docs.vision_or_default(), Docs::DEFAULT_VISION);
385    }
386
387    #[test]
388    fn derive_acronym_multi_word() {
389        assert_eq!(derive_acronym("My Cool Project"), "MCP");
390    }
391
392    #[test]
393    fn derive_acronym_single_word() {
394        assert_eq!(derive_acronym("Joy"), "JOY");
395    }
396
397    #[test]
398    fn derive_acronym_long_name() {
399        assert_eq!(derive_acronym("A Very Long Project Name"), "AVLP");
400    }
401
402    #[test]
403    fn derive_acronym_single_long_word() {
404        assert_eq!(derive_acronym("Platform"), "PLA");
405    }
406
407    // -----------------------------------------------------------------------
408    // ModeDefaults deserialization tests
409    // -----------------------------------------------------------------------
410
411    #[test]
412    fn mode_defaults_flat_yaml_roundtrip() {
413        let yaml = r#"
414default: interactive
415implement: collaborative
416review: pairing
417"#;
418        let parsed: ModeDefaults = serde_yaml_ng::from_str(yaml).unwrap();
419        assert_eq!(parsed.default, InteractionLevel::Interactive);
420        assert_eq!(
421            parsed.capabilities[&Capability::Implement],
422            InteractionLevel::Collaborative
423        );
424        assert_eq!(
425            parsed.capabilities[&Capability::Review],
426            InteractionLevel::Pairing
427        );
428    }
429
430    #[test]
431    fn mode_defaults_empty_yaml() {
432        let yaml = "{}";
433        let parsed: ModeDefaults = serde_yaml_ng::from_str(yaml).unwrap();
434        assert_eq!(parsed.default, InteractionLevel::Collaborative);
435        assert!(parsed.capabilities.is_empty());
436    }
437
438    #[test]
439    fn mode_defaults_only_default() {
440        let yaml = "default: pairing";
441        let parsed: ModeDefaults = serde_yaml_ng::from_str(yaml).unwrap();
442        assert_eq!(parsed.default, InteractionLevel::Pairing);
443        assert!(parsed.capabilities.is_empty());
444    }
445
446    #[test]
447    fn ai_defaults_yaml_roundtrip() {
448        let yaml = r#"
449capabilities:
450  - implement
451  - review
452"#;
453        let parsed: AiDefaults = serde_yaml_ng::from_str(yaml).unwrap();
454        assert_eq!(parsed.capabilities.len(), 2);
455        assert_eq!(parsed.capabilities[0], Capability::Implement);
456    }
457
458    // -----------------------------------------------------------------------
459    // resolve_mode tests
460    // -----------------------------------------------------------------------
461
462    fn defaults_with_mode(mode: InteractionLevel) -> ModeDefaults {
463        ModeDefaults {
464            default: mode,
465            ..Default::default()
466        }
467    }
468
469    fn defaults_with_cap_mode(cap: Capability, mode: InteractionLevel) -> ModeDefaults {
470        let mut d = ModeDefaults::default();
471        d.capabilities.insert(cap, mode);
472        d
473    }
474
475    #[test]
476    fn resolve_mode_uses_global_default() {
477        let raw = defaults_with_mode(InteractionLevel::Collaborative);
478        let effective = raw.clone();
479        let (mode, source) = resolve_mode(&Capability::Implement, &raw, &effective, None, None);
480        assert_eq!(mode, InteractionLevel::Collaborative);
481        assert_eq!(source, ModeSource::Default);
482    }
483
484    #[test]
485    fn resolve_mode_uses_per_capability_default() {
486        let raw = defaults_with_cap_mode(Capability::Review, InteractionLevel::Interactive);
487        let effective = raw.clone();
488        let (mode, source) = resolve_mode(&Capability::Review, &raw, &effective, None, None);
489        assert_eq!(mode, InteractionLevel::Interactive);
490        assert_eq!(source, ModeSource::Default);
491    }
492
493    #[test]
494    fn resolve_mode_project_override_detected() {
495        let raw = defaults_with_cap_mode(Capability::Implement, InteractionLevel::Collaborative);
496        let effective =
497            defaults_with_cap_mode(Capability::Implement, InteractionLevel::Interactive);
498        let (mode, source) = resolve_mode(&Capability::Implement, &raw, &effective, None, None);
499        assert_eq!(mode, InteractionLevel::Interactive);
500        assert_eq!(source, ModeSource::Project);
501    }
502
503    #[test]
504    fn resolve_mode_personal_overrides_default() {
505        let raw = defaults_with_mode(InteractionLevel::Collaborative);
506        let effective = raw.clone();
507        let (mode, source) = resolve_mode(
508            &Capability::Implement,
509            &raw,
510            &effective,
511            Some(InteractionLevel::Pairing),
512            None,
513        );
514        assert_eq!(mode, InteractionLevel::Pairing);
515        assert_eq!(source, ModeSource::Personal);
516    }
517
518    #[test]
519    fn resolve_mode_max_mode_clamps_upward() {
520        let raw = defaults_with_mode(InteractionLevel::Autonomous);
521        let effective = raw.clone();
522        let cap_config = CapabilityConfig {
523            max_mode: Some(InteractionLevel::Supervised),
524            ..Default::default()
525        };
526        let (mode, source) = resolve_mode(
527            &Capability::Implement,
528            &raw,
529            &effective,
530            None,
531            Some(&cap_config),
532        );
533        assert_eq!(mode, InteractionLevel::Supervised);
534        assert_eq!(source, ModeSource::ProjectMax);
535    }
536
537    #[test]
538    fn resolve_mode_max_mode_does_not_lower() {
539        let raw = defaults_with_mode(InteractionLevel::Pairing);
540        let effective = raw.clone();
541        let cap_config = CapabilityConfig {
542            max_mode: Some(InteractionLevel::Supervised),
543            ..Default::default()
544        };
545        let (mode, source) = resolve_mode(
546            &Capability::Implement,
547            &raw,
548            &effective,
549            None,
550            Some(&cap_config),
551        );
552        // Pairing > Supervised, so no clamping
553        assert_eq!(mode, InteractionLevel::Pairing);
554        assert_eq!(source, ModeSource::Default);
555    }
556
557    #[test]
558    fn resolve_mode_personal_clamped_by_max() {
559        let raw = defaults_with_mode(InteractionLevel::Collaborative);
560        let effective = raw.clone();
561        let cap_config = CapabilityConfig {
562            max_mode: Some(InteractionLevel::Interactive),
563            ..Default::default()
564        };
565        let (mode, source) = resolve_mode(
566            &Capability::Implement,
567            &raw,
568            &effective,
569            Some(InteractionLevel::Autonomous),
570            Some(&cap_config),
571        );
572        // Personal is Autonomous but max is Interactive, clamp up
573        assert_eq!(mode, InteractionLevel::Interactive);
574        assert_eq!(source, ModeSource::ProjectMax);
575    }
576
577    // -----------------------------------------------------------------------
578    // Item mode serialization
579    // -----------------------------------------------------------------------
580
581    #[test]
582    fn item_mode_field_roundtrip() {
583        use crate::model::item::{Item, ItemType, Priority};
584
585        let mut item = Item::new(
586            "TST-0001".into(),
587            "Test".into(),
588            ItemType::Task,
589            Priority::Medium,
590            vec![],
591        );
592        item.mode = Some(InteractionLevel::Pairing);
593
594        let yaml = serde_yaml_ng::to_string(&item).unwrap();
595        assert!(yaml.contains("mode: pairing"), "mode field not serialized");
596
597        let parsed: Item = serde_yaml_ng::from_str(&yaml).unwrap();
598        assert_eq!(parsed.mode, Some(InteractionLevel::Pairing));
599    }
600
601    #[test]
602    fn item_mode_field_absent_when_none() {
603        use crate::model::item::{Item, ItemType, Priority};
604
605        let item = Item::new(
606            "TST-0002".into(),
607            "Test".into(),
608            ItemType::Task,
609            Priority::Medium,
610            vec![],
611        );
612        assert_eq!(item.mode, None);
613
614        let yaml = serde_yaml_ng::to_string(&item).unwrap();
615        assert!(
616            !yaml.contains("mode:"),
617            "mode field should not appear when None"
618        );
619    }
620
621    #[test]
622    fn item_mode_deserialized_from_existing_yaml() {
623        let yaml = r#"
624id: TST-0003
625title: Test
626type: task
627status: new
628priority: medium
629mode: interactive
630created: "2026-01-01T00:00:00+00:00"
631updated: "2026-01-01T00:00:00+00:00"
632"#;
633        let item: crate::model::item::Item = serde_yaml_ng::from_str(yaml).unwrap();
634        assert_eq!(item.mode, Some(InteractionLevel::Interactive));
635    }
636
637    // -----------------------------------------------------------------------
638    // Full four-layer resolution scenario
639    // -----------------------------------------------------------------------
640
641    #[test]
642    fn resolve_mode_full_scenario() {
643        // Joy default: implement = collaborative
644        let raw = defaults_with_cap_mode(Capability::Implement, InteractionLevel::Collaborative);
645        // Project override: implement = interactive
646        let effective =
647            defaults_with_cap_mode(Capability::Implement, InteractionLevel::Interactive);
648        // Personal preference: autonomous
649        let personal = Some(InteractionLevel::Autonomous);
650        // Project max-mode: supervised (minimum interactivity)
651        let cap_config = CapabilityConfig {
652            max_mode: Some(InteractionLevel::Supervised),
653            ..Default::default()
654        };
655
656        let (mode, source) = resolve_mode(
657            &Capability::Implement,
658            &raw,
659            &effective,
660            personal,
661            Some(&cap_config),
662        );
663
664        // Personal (autonomous) < max (supervised), so clamped up to supervised
665        assert_eq!(mode, InteractionLevel::Supervised);
666        assert_eq!(source, ModeSource::ProjectMax);
667    }
668
669    #[test]
670    fn resolve_mode_all_layers_no_clamping() {
671        // Joy default: implement = collaborative
672        let raw = defaults_with_cap_mode(Capability::Implement, InteractionLevel::Collaborative);
673        // Project override: implement = interactive
674        let effective =
675            defaults_with_cap_mode(Capability::Implement, InteractionLevel::Interactive);
676        // Personal preference: pairing (more interactive than project)
677        let personal = Some(InteractionLevel::Pairing);
678        // No max-mode
679        let cap_config = CapabilityConfig::default();
680
681        let (mode, source) = resolve_mode(
682            &Capability::Implement,
683            &raw,
684            &effective,
685            personal,
686            Some(&cap_config),
687        );
688
689        // Personal wins, no clamping
690        assert_eq!(mode, InteractionLevel::Pairing);
691        assert_eq!(source, ModeSource::Personal);
692    }
693}