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