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/// Serialize the member map, resolving opaque ids to their display value when
13/// in presentation mode (`--json` output, ADR-042) and keeping the raw id for
14/// on-disk persistence. The map key stays a raw id in memory; only output is
15/// resolved, so an id never leaves Joy in `--json` either.
16fn serialize_members<S>(
17    members: &BTreeMap<String, Member>,
18    serializer: S,
19) -> Result<S::Ok, S::Error>
20where
21    S: serde::Serializer,
22{
23    use serde::ser::SerializeMap;
24    let present = crate::member_ref::presentation_active();
25    let mut map = serializer.serialize_map(Some(members.len()))?;
26    for (k, v) in members {
27        if present {
28            map.serialize_entry(&crate::member_ref::resolve_str(k), v)?;
29        } else {
30            map.serialize_entry(k, v)?;
31        }
32    }
33    map.end()
34}
35
36#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
37pub struct Project {
38    pub name: String,
39    #[serde(default, skip_serializing_if = "Option::is_none")]
40    pub acronym: Option<String>,
41    #[serde(default, skip_serializing_if = "Option::is_none")]
42    pub description: Option<String>,
43    #[serde(default = "default_language")]
44    pub language: String,
45    #[serde(default, skip_serializing_if = "Option::is_none")]
46    pub forge: Option<String>,
47    /// Member-PII privacy mode (ADR-042). Absent means `Open` (today's
48    /// behaviour: cleartext e-mail in the member entry). `Anonymous` moves
49    /// member e-mail into an encrypted members.yaml and keys members by an
50    /// opaque id plus an `email_match` verifier. Read via `privacy_mode()`;
51    /// changed only by the dedicated mode-transition command, never by a
52    /// bare field write (the switch is an atomic migration).
53    #[serde(default, skip_serializing_if = "Option::is_none")]
54    pub privacy: Option<PrivacyMode>,
55    #[serde(default, skip_serializing_if = "Docs::is_empty")]
56    pub docs: Docs,
57    #[serde(
58        default,
59        skip_serializing_if = "BTreeMap::is_empty",
60        serialize_with = "serialize_members"
61    )]
62    pub members: BTreeMap<String, Member>,
63    /// Crypt zone registry. Empty / absent means encryption is not in
64    /// use; `crypt_wraps` on members and `crypt_zone` on items only have
65    /// meaning relative to the zones declared here. See ADR-038 and
66    /// vision/guardianship/Crypt.md.
67    #[serde(default, skip_serializing_if = "CryptConfig::is_empty")]
68    pub crypt: CryptConfig,
69    pub created: DateTime<Utc>,
70}
71
72/// Per-project member-PII privacy mode (ADR-042). Stored in project.yaml
73/// (committed, project-wide); absent means `Open`. Inspected via
74/// `joy project get privacy`; the switch to `Anonymous` is an atomic
75/// migration owned by the mode-transition command, not a bare set.
76#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
77#[serde(rename_all = "lowercase")]
78pub enum PrivacyMode {
79    /// Cleartext member e-mail in project.yaml (today's behaviour).
80    #[default]
81    Open,
82    /// Member e-mail lives in an encrypted members.yaml; project.yaml
83    /// carries an opaque member id and an `email_match` verifier.
84    Anonymous,
85}
86
87impl std::fmt::Display for PrivacyMode {
88    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
89        match self {
90            Self::Open => write!(f, "open"),
91            Self::Anonymous => write!(f, "anonymous"),
92        }
93    }
94}
95
96/// Top-level Crypt configuration. Holds the zone registry; per-member
97/// wraps live on `Member.crypt_wraps`, per-item zone references live on
98/// `Item.crypt_zone`. The default zone uses the conventional name
99/// `"default"` and is auto-created on first `joy crypt add`.
100#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
101pub struct CryptConfig {
102    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
103    pub zones: BTreeMap<String, CryptZone>,
104}
105
106impl CryptConfig {
107    pub fn is_empty(&self) -> bool {
108        self.zones.is_empty()
109    }
110}
111
112/// A single Crypt zone: marked paths and project-wide properties. The
113/// zone key itself is never stored in plaintext; it lives only as
114/// per-member wraps under `Member.crypt_wraps[<zone-name>]` (humans) and
115/// per-(operator, AI) wraps under `delegations[<ai-member>][<operator>]`
116/// (AI Tool, ADR-041).
117#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
118pub struct CryptZone {
119    /// Path patterns (gitattributes-style globs) that belong to this
120    /// zone. Empty list means item-only encryption (zone references
121    /// come from items via `crypt_zone`).
122    #[serde(default, skip_serializing_if = "Vec::is_empty")]
123    pub paths: Vec<String>,
124    /// Per-(operator, AI) zone-key wraps for AI Tool delegations
125    /// (ADR-041 §3-4). Outer key is the AI member id (e.g.
126    /// `ai:claude@joy`); inner key is the operator email; value is the
127    /// hex-encoded X25519 wrap of the zone key against the operator's
128    /// stable delegation public key.
129    ///
130    /// One wrap per (operator, AI) pair, regardless of how many tokens
131    /// the operator has issued. Token issuance writes nothing here; the
132    /// embedded delegation private key in `--crypt` tokens is what the
133    /// AI uses to unwrap (ADR-041 §5).
134    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
135    pub delegations: BTreeMap<String, BTreeMap<String, String>>,
136}
137
138/// Configurable paths to the project's reference documentation, relative to
139/// the project root. Used by `joy ai init` to support existing repos with
140/// non-default doc layouts and read by AI tools via `joy project get docs.<key>`.
141#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
142pub struct Docs {
143    #[serde(default, skip_serializing_if = "Option::is_none")]
144    pub architecture: Option<String>,
145    #[serde(default, skip_serializing_if = "Option::is_none")]
146    pub vision: Option<String>,
147    #[serde(default, skip_serializing_if = "Option::is_none")]
148    pub contributing: Option<String>,
149}
150
151impl Docs {
152    pub const DEFAULT_ARCHITECTURE: &'static str = "docs/dev/architecture/README.md";
153    pub const DEFAULT_VISION: &'static str = "docs/dev/vision/README.md";
154    pub const DEFAULT_CONTRIBUTING: &'static str = "CONTRIBUTING.md";
155
156    pub fn is_empty(&self) -> bool {
157        self.architecture.is_none() && self.vision.is_none() && self.contributing.is_none()
158    }
159
160    /// Configured architecture path or the default if unset.
161    pub fn architecture_or_default(&self) -> &str {
162        self.architecture
163            .as_deref()
164            .unwrap_or(Self::DEFAULT_ARCHITECTURE)
165    }
166
167    /// Configured vision path or the default if unset.
168    pub fn vision_or_default(&self) -> &str {
169        self.vision.as_deref().unwrap_or(Self::DEFAULT_VISION)
170    }
171
172    /// Configured contributing path or the default if unset.
173    pub fn contributing_or_default(&self) -> &str {
174        self.contributing
175            .as_deref()
176            .unwrap_or(Self::DEFAULT_CONTRIBUTING)
177    }
178}
179
180#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
181pub struct Member {
182    pub capabilities: MemberCapabilities,
183    #[serde(default, skip_serializing_if = "Option::is_none")]
184    pub verify_key: Option<String>,
185    #[serde(default, skip_serializing_if = "Option::is_none")]
186    pub kdf_nonce: Option<String>,
187    /// AES-256-GCM ciphertext of the member's identity seed, encrypted
188    /// under a KEK derived from passphrase + kdf_nonce via Argon2id
189    /// (ADR-039). Hex-encoded `nonce || ciphertext || tag`.
190    #[serde(default, skip_serializing_if = "Option::is_none")]
191    pub seed_wrap_passphrase: Option<String>,
192    /// AES-256-GCM ciphertext of the same seed, encrypted under a KEK
193    /// derived from a recovery key via Argon2id (ADR-039). The recovery
194    /// key itself is generated at `joy auth init`, displayed once, and
195    /// stored externally by the user. Hex-encoded.
196    #[serde(default, skip_serializing_if = "Option::is_none")]
197    pub seed_wrap_recovery: Option<String>,
198    #[serde(default, skip_serializing_if = "Option::is_none")]
199    pub enrollment_verifier: Option<String>,
200    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
201    pub ai_delegations: BTreeMap<String, AiDelegationEntry>,
202    /// Per-member Crypt zone-key wraps. Map from zone name to the
203    /// hex-encoded `nonce || ciphertext || tag` produced by
204    /// `joy_crypt::wrap::wrap` over the zone key. The KEK derives from
205    /// the member's identity seed via HKDF-SHA256 with a fixed
206    /// "crypt-member-kek" tag.
207    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
208    pub crypt_wraps: BTreeMap<String, String>,
209    /// Non-reversible e-mail verifier (ADR-042 anonymous mode). Hex of
210    /// HKDF-SHA256 over normalize(email) keyed by `kdf_nonce`. Present only in
211    /// anonymous mode, where it replaces the cleartext e-mail map key. The
212    /// platform compares this against verified account e-mails to decide
213    /// membership without decrypting anything.
214    #[serde(default, skip_serializing_if = "Option::is_none")]
215    pub email_match: Option<String>,
216    /// Wrap of the members.yaml zone key for this member (ADR-042 anonymous
217    /// mode). Pairwise X25519 wrap (`crypt::wrap_for_member`), unwrappable with
218    /// the member's identity seed. Present only in anonymous mode.
219    #[serde(default, skip_serializing_if = "Option::is_none")]
220    pub members_wrap: Option<String>,
221    #[serde(default, skip_serializing_if = "Option::is_none")]
222    pub attestation: Option<Attestation>,
223}
224
225/// Per-member attestation: a signature by a manage member over a stable
226/// subset of the member's fields (email, capabilities, enrollment_verifier).
227/// Verified locally against project.yaml by looking up the attester's
228/// verify_key in the same file. The founder is the sole member allowed to
229/// have no attestation in a fresh project; once any additional manage
230/// member is added, that member implicitly reverse-attests the founder.
231#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
232pub struct Attestation {
233    /// The member who produced the signature (must be manage-capable at signing
234    /// time). Resolves to name/e-mail on display and in `--json`; raw at rest.
235    pub attester: crate::member_ref::MemberRef,
236    /// The fields this signature covers. verify_key is intentionally
237    /// excluded so that passphrase changes do not break existing
238    /// attestations.
239    pub signed_fields: AttestationSignedFields,
240    /// When the attestation was produced.
241    pub signed_at: chrono::DateTime<chrono::Utc>,
242    /// Hex-encoded Ed25519 signature over the canonical serialization of
243    /// `signed_fields`.
244    pub signature: String,
245}
246
247/// The exact subset of a member's state covered by the attestation
248/// signature. Changes to any of these fields invalidate the signature.
249///
250/// The serde key for `enrollment_verifier` is pinned to the historical
251/// name `otp_hash` (per ADR-035) so signatures created before the field
252/// rename remain bit-identically valid. Do not change the rename pin
253/// without coordinating an attestation re-signing pass.
254#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
255pub struct AttestationSignedFields {
256    pub email: String,
257    pub capabilities: MemberCapabilities,
258    #[serde(default, rename = "otp_hash", skip_serializing_if = "Option::is_none")]
259    pub enrollment_verifier: Option<String>,
260}
261
262impl AttestationSignedFields {
263    /// Produce a deterministic byte sequence for signing/verification.
264    /// Stability relies on: (a) BTreeMap ordering in MemberCapabilities::Specific,
265    /// (b) struct field declaration order via serde_json, (c) skip-empty rules
266    /// being identical on write and read.
267    pub fn canonical_bytes(&self) -> Vec<u8> {
268        serde_json::to_vec(self).expect("AttestationSignedFields canonicalization")
269    }
270}
271
272/// A stable per-(human, AI) delegation key.
273///
274/// Under ADR-037 the delegation seed is deterministically derived from the
275/// human's Argon2id-derived identity material (`derive_key(passphrase, kdf_nonce)`)
276/// plus the per-(human, AI) `delegation_salt` recorded here. Identical inputs
277/// on any of the human's machines yield the same Ed25519 keypair, so the same
278/// delegation is reachable from anywhere without per-machine state in
279/// `project.yaml`. The matching private seed is cached at
280/// `~/.local/state/joy/delegations/<project>/<ai-member>.key` (0600); a missing
281/// cache is regenerated transparently from passphrase + salt at next use.
282///
283/// Legacy entries (created under ADR-033 §1, before ADR-037) carry no
284/// `delegation_salt`. They keep working on the machine whose local cache holds
285/// the matching random seed; rotating under the new code populates the salt
286/// and unblocks every other machine going forward.
287#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
288pub struct AiDelegationEntry {
289    /// Public verifier of the stable delegation keypair (hex-encoded Ed25519).
290    /// Used to verify the binding signature on delegation tokens.
291    pub delegation_verifier: String,
292    /// 32-byte hex salt feeding HKDF-SHA256 over the human's identity material
293    /// (ADR-037). `None` for legacy entries created under ADR-033 §1; populated
294    /// by `joy ai rotate` and by every fresh delegation issued under ADR-037.
295    #[serde(default, skip_serializing_if = "Option::is_none")]
296    pub delegation_salt: Option<String>,
297    /// When this delegation was first issued.
298    pub created: chrono::DateTime<chrono::Utc>,
299    /// When this delegation was last rotated, if ever.
300    #[serde(default, skip_serializing_if = "Option::is_none")]
301    pub rotated: Option<chrono::DateTime<chrono::Utc>>,
302}
303
304#[derive(Debug, Clone, PartialEq)]
305pub enum MemberCapabilities {
306    All,
307    Specific(BTreeMap<Capability, CapabilityConfig>),
308}
309
310#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
311pub struct CapabilityConfig {
312    #[serde(rename = "max-mode", default, skip_serializing_if = "Option::is_none")]
313    pub max_mode: Option<InteractionLevel>,
314    #[serde(
315        rename = "max-cost-per-job",
316        default,
317        skip_serializing_if = "Option::is_none"
318    )]
319    pub max_cost_per_job: Option<f64>,
320}
321
322// ---------------------------------------------------------------------------
323// Mode defaults (from project.defaults.yaml, overridable in project.yaml)
324// ---------------------------------------------------------------------------
325
326/// Interaction mode defaults: a global default plus optional per-capability overrides.
327/// Deserializes from flat YAML like: `{ default: collaborative, implement: autonomous }`.
328#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
329pub struct ModeDefaults {
330    /// Fallback mode when no per-capability mode is set.
331    #[serde(default)]
332    pub default: InteractionLevel,
333    /// Per-capability mode overrides (flattened into the same map).
334    #[serde(flatten, default)]
335    pub capabilities: BTreeMap<Capability, InteractionLevel>,
336}
337
338/// Default capabilities granted to AI members by joy ai init.
339/// Loaded from `ai-defaults.capabilities` in project.defaults.yaml.
340#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
341pub struct AiDefaults {
342    #[serde(default, skip_serializing_if = "Vec::is_empty")]
343    pub capabilities: Vec<Capability>,
344}
345
346/// Source of a resolved interaction mode.
347#[derive(Debug, Clone, Copy, PartialEq, Eq)]
348pub enum ModeSource {
349    /// From project.defaults.yaml (Joy's recommendation).
350    Default,
351    /// From project.yaml agents.defaults override.
352    Project,
353    /// From config.yaml personal preference.
354    Personal,
355    /// From item-level override (future).
356    Item,
357    /// Clamped by max-mode from project.yaml member config.
358    ProjectMax,
359}
360
361impl std::fmt::Display for ModeSource {
362    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
363        match self {
364            Self::Default => write!(f, "default"),
365            Self::Project => write!(f, "project"),
366            Self::Personal => write!(f, "personal"),
367            Self::Item => write!(f, "item"),
368            Self::ProjectMax => write!(f, "project max"),
369        }
370    }
371}
372
373/// Resolve the effective interaction mode for a given capability.
374///
375/// Resolution order (later wins):
376/// 1. Effective defaults global mode (project.defaults.yaml merged with project.yaml)
377/// 2. Effective defaults per-capability mode
378/// 3. Personal config preference
379///
380/// All clamped by max-mode from the member's CapabilityConfig.
381pub fn resolve_mode(
382    capability: &Capability,
383    raw_defaults: &ModeDefaults,
384    effective_defaults: &ModeDefaults,
385    personal_mode: Option<InteractionLevel>,
386    member_cap_config: Option<&CapabilityConfig>,
387) -> (InteractionLevel, ModeSource) {
388    // 1. Global fallback from effective defaults
389    let mut mode = effective_defaults.default;
390    let mut source = if effective_defaults.default != raw_defaults.default {
391        ModeSource::Project
392    } else {
393        ModeSource::Default
394    };
395
396    // 2. Per-capability default
397    if let Some(&cap_mode) = effective_defaults.capabilities.get(capability) {
398        mode = cap_mode;
399        let from_raw = raw_defaults.capabilities.get(capability) == Some(&cap_mode);
400        source = if from_raw {
401            ModeSource::Default
402        } else {
403            ModeSource::Project
404        };
405    }
406
407    // 3. Personal preference
408    if let Some(personal) = personal_mode {
409        mode = personal;
410        source = ModeSource::Personal;
411    }
412
413    // 4. Clamp by max-mode (minimum interactivity required)
414    if let Some(cap_config) = member_cap_config {
415        if let Some(max) = cap_config.max_mode {
416            if mode < max {
417                mode = max;
418                source = ModeSource::ProjectMax;
419            }
420        }
421    }
422
423    (mode, source)
424}
425
426// Custom serde for MemberCapabilities: "all" string or map of capabilities
427impl Serialize for MemberCapabilities {
428    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
429        match self {
430            MemberCapabilities::All => serializer.serialize_str("all"),
431            MemberCapabilities::Specific(map) => map.serialize(serializer),
432        }
433    }
434}
435
436impl<'de> Deserialize<'de> for MemberCapabilities {
437    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
438        let value = serde_yaml_ng::Value::deserialize(deserializer)?;
439        match &value {
440            serde_yaml_ng::Value::String(s) if s == "all" => Ok(MemberCapabilities::All),
441            serde_yaml_ng::Value::Mapping(_) => {
442                let map: BTreeMap<Capability, CapabilityConfig> =
443                    serde_yaml_ng::from_value(value).map_err(serde::de::Error::custom)?;
444                Ok(MemberCapabilities::Specific(map))
445            }
446            _ => Err(serde::de::Error::custom(
447                "expected \"all\" or a map of capabilities",
448            )),
449        }
450    }
451}
452
453impl Member {
454    /// Create a member with the given capabilities and no auth fields.
455    pub fn new(capabilities: MemberCapabilities) -> Self {
456        Self {
457            capabilities,
458            verify_key: None,
459            kdf_nonce: None,
460            seed_wrap_passphrase: None,
461            seed_wrap_recovery: None,
462            enrollment_verifier: None,
463            ai_delegations: BTreeMap::new(),
464            crypt_wraps: BTreeMap::new(),
465            email_match: None,
466            members_wrap: None,
467            attestation: None,
468        }
469    }
470
471    /// Check whether this member has a specific capability.
472    pub fn has_capability(&self, cap: &Capability) -> bool {
473        match &self.capabilities {
474            MemberCapabilities::All => true,
475            MemberCapabilities::Specific(map) => map.contains_key(cap),
476        }
477    }
478}
479
480/// Check whether a member ID represents an AI member.
481pub fn is_ai_member(id: &str) -> bool {
482    id.starts_with("ai:")
483}
484
485/// One-line description for a `joy project get` key. Returned by
486/// `--describe` so the CLI is the single source of truth for what
487/// each project field means. Mirrors `crate::model::config::describe_value`
488/// for the config tree.
489pub fn describe_value(key: &str, _value: &serde_json::Value) -> Option<String> {
490    let text = match key {
491        "name" => "human-readable project name",
492        "acronym" => "short prefix used in item IDs",
493        "description" => "one-paragraph project description",
494        "language" => "project language for written artifacts (titles, comments, commits)",
495        "forge" => {
496            "release forge override (e.g. github, none); unset = auto-detect from git remotes"
497        }
498        "privacy" => {
499            "member-PII privacy mode: none (default, behaves as open), open, or anonymous (e-mail in an encrypted members.yaml, opaque ids in project.yaml)"
500        }
501        "release.version-files" => {
502            "paths whose version strings `joy release bump` rewrites; managed with `joy project set release.version-files --add/--rm/<csv>`"
503        }
504        "created" => "ISO timestamp when the project was initialized",
505        "docs.architecture" => "path to the technical architecture document",
506        "docs.vision" => "path to the product-vision document",
507        "docs.contributing" => "path to the contributing guide",
508        _ => return None,
509    };
510    Some(text.to_string())
511}
512
513fn default_language() -> String {
514    "en".to_string()
515}
516
517impl Project {
518    pub fn new(name: String, acronym: Option<String>) -> Self {
519        Self {
520            name,
521            acronym,
522            description: None,
523            language: default_language(),
524            forge: None,
525            privacy: None,
526            docs: Docs::default(),
527            members: BTreeMap::new(),
528            crypt: CryptConfig::default(),
529            created: Utc::now(),
530        }
531    }
532
533    /// The effective privacy mode: `Open` when unset (ADR-042).
534    pub fn privacy_mode(&self) -> PrivacyMode {
535        self.privacy.unwrap_or_default()
536    }
537}
538
539/// Validate and normalize a project acronym.
540///
541/// Acronyms drive item ID prefixes (`ACRONYM-XXXX`) and must therefore be
542/// ASCII, filesystem-safe, and short. Rules: ASCII uppercase letters (A-Z) or
543/// digits (0-9), length 2-8 after trimming. Input is trimmed and uppercased;
544/// the normalized form is returned on success so callers can store it as-is.
545pub fn validate_acronym(value: &str) -> Result<String, String> {
546    let normalized = value.trim().to_uppercase();
547    if normalized.len() < 2 || normalized.len() > 8 {
548        return Err(format!(
549            "acronym must be 2-8 characters, got {} ('{}')",
550            normalized.len(),
551            normalized
552        ));
553    }
554    for (i, c) in normalized.chars().enumerate() {
555        if !(c.is_ascii_uppercase() || c.is_ascii_digit()) {
556            return Err(format!(
557                "acronym character '{c}' at position {i} is not A-Z or 0-9"
558            ));
559        }
560    }
561    Ok(normalized)
562}
563
564/// Derive an acronym from a project name.
565/// Takes the first letter of each word, uppercase, max 4 characters.
566/// Single words use up to 3 uppercase characters.
567pub fn derive_acronym(name: &str) -> String {
568    let words: Vec<&str> = name.split_whitespace().collect();
569    if words.len() == 1 {
570        words[0]
571            .chars()
572            .filter(|c| c.is_alphanumeric())
573            .take(3)
574            .collect::<String>()
575            .to_uppercase()
576    } else {
577        words
578            .iter()
579            .filter_map(|w| w.chars().next())
580            .filter(|c| c.is_alphanumeric())
581            .take(4)
582            .collect::<String>()
583            .to_uppercase()
584    }
585}
586
587#[cfg(test)]
588mod tests {
589    use super::*;
590
591    #[test]
592    fn privacy_mode_defaults_to_open() {
593        let project = Project::new("T".into(), Some("T".into()));
594        assert_eq!(project.privacy, None);
595        assert_eq!(project.privacy_mode(), PrivacyMode::Open);
596    }
597
598    #[test]
599    fn privacy_absent_from_yaml_by_default() {
600        let project = Project::new("T".into(), Some("T".into()));
601        let yaml = serde_yaml_ng::to_string(&project).unwrap();
602        assert!(
603            !yaml.contains("privacy"),
604            "none (absent) is the default; got:\n{yaml}"
605        );
606    }
607
608    #[test]
609    fn privacy_open_serializes_explicitly() {
610        let mut project = Project::new("T".into(), Some("T".into()));
611        project.privacy = Some(PrivacyMode::Open);
612        let yaml = serde_yaml_ng::to_string(&project).unwrap();
613        assert!(yaml.contains("privacy: open"), "got:\n{yaml}");
614        let parsed: Project = serde_yaml_ng::from_str(&yaml).unwrap();
615        assert_eq!(parsed.privacy, Some(PrivacyMode::Open));
616    }
617
618    #[test]
619    fn privacy_mode_accessor_maps_none_and_open_to_open() {
620        let mut project = Project::new("T".into(), Some("T".into()));
621        assert_eq!(project.privacy_mode(), PrivacyMode::Open);
622        project.privacy = Some(PrivacyMode::Open);
623        assert_eq!(project.privacy_mode(), PrivacyMode::Open);
624        project.privacy = Some(PrivacyMode::Anonymous);
625        assert_eq!(project.privacy_mode(), PrivacyMode::Anonymous);
626    }
627
628    #[test]
629    fn privacy_anonymous_roundtrips() {
630        let mut project = Project::new("T".into(), Some("T".into()));
631        project.privacy = Some(PrivacyMode::Anonymous);
632        let yaml = serde_yaml_ng::to_string(&project).unwrap();
633        assert!(yaml.contains("privacy: anonymous"), "got:\n{yaml}");
634        let parsed: Project = serde_yaml_ng::from_str(&yaml).unwrap();
635        assert_eq!(parsed.privacy, Some(PrivacyMode::Anonymous));
636        assert_eq!(parsed.privacy_mode(), PrivacyMode::Anonymous);
637    }
638
639    #[test]
640    fn privacy_mode_display() {
641        assert_eq!(PrivacyMode::Open.to_string(), "open");
642        assert_eq!(PrivacyMode::Anonymous.to_string(), "anonymous");
643    }
644
645    #[test]
646    fn project_roundtrip() {
647        let project = Project::new("Test Project".into(), Some("TP".into()));
648        let yaml = serde_yaml_ng::to_string(&project).unwrap();
649        let parsed: Project = serde_yaml_ng::from_str(&yaml).unwrap();
650        assert_eq!(project, parsed);
651    }
652
653    #[test]
654    fn describe_value_covers_documented_keys() {
655        let dummy = serde_json::Value::Null;
656        for key in &[
657            "name",
658            "acronym",
659            "description",
660            "language",
661            "forge",
662            "release.version-files",
663            "created",
664            "docs.architecture",
665            "docs.vision",
666            "docs.contributing",
667        ] {
668            assert!(
669                describe_value(key, &dummy).is_some(),
670                "missing description for project key {key}"
671            );
672        }
673        assert!(describe_value("unknown", &dummy).is_none());
674    }
675
676    // -----------------------------------------------------------------------
677    // ai_delegations (ADR-033) tests
678    // -----------------------------------------------------------------------
679
680    #[test]
681    fn ai_delegations_omitted_when_empty() {
682        let mut m = Member::new(MemberCapabilities::All);
683        assert!(m.ai_delegations.is_empty());
684        let yaml = serde_yaml_ng::to_string(&m).unwrap();
685        assert!(
686            !yaml.contains("ai_delegations"),
687            "empty ai_delegations should be skipped, got: {yaml}"
688        );
689        // sanity: round-trips empty
690        m.verify_key = Some("aa".repeat(32));
691        let yaml = serde_yaml_ng::to_string(&m).unwrap();
692        let parsed: Member = serde_yaml_ng::from_str(&yaml).unwrap();
693        assert_eq!(m, parsed);
694    }
695
696    #[test]
697    fn ai_delegations_yaml_roundtrip() {
698        let mut m = Member::new(MemberCapabilities::All);
699        m.verify_key = Some("aa".repeat(32));
700        m.kdf_nonce = Some("bb".repeat(32));
701        m.ai_delegations.insert(
702            "ai:claude@joy".into(),
703            AiDelegationEntry {
704                delegation_verifier: "cc".repeat(32),
705                delegation_salt: None,
706                created: chrono::DateTime::parse_from_rfc3339("2026-04-15T10:00:00Z")
707                    .unwrap()
708                    .with_timezone(&chrono::Utc),
709                rotated: None,
710            },
711        );
712        let yaml = serde_yaml_ng::to_string(&m).unwrap();
713        assert!(yaml.contains("ai_delegations:"));
714        assert!(yaml.contains("ai:claude@joy:"));
715        assert!(yaml.contains("delegation_verifier:"));
716        assert!(
717            !yaml.contains("delegation_salt:"),
718            "unset delegation_salt should be skipped (legacy entry)"
719        );
720        assert!(
721            !yaml.contains("rotated:"),
722            "unset rotated should be skipped"
723        );
724
725        let parsed: Member = serde_yaml_ng::from_str(&yaml).unwrap();
726        assert_eq!(m, parsed);
727    }
728
729    #[test]
730    fn ai_delegations_with_rotated_roundtrips() {
731        let mut m = Member::new(MemberCapabilities::All);
732        let created = chrono::DateTime::parse_from_rfc3339("2026-04-01T10:00:00Z")
733            .unwrap()
734            .with_timezone(&chrono::Utc);
735        let rotated = chrono::DateTime::parse_from_rfc3339("2026-04-15T12:30:00Z")
736            .unwrap()
737            .with_timezone(&chrono::Utc);
738        m.ai_delegations.insert(
739            "ai:claude@joy".into(),
740            AiDelegationEntry {
741                delegation_verifier: "dd".repeat(32),
742                delegation_salt: None,
743                created,
744                rotated: Some(rotated),
745            },
746        );
747        let yaml = serde_yaml_ng::to_string(&m).unwrap();
748        assert!(yaml.contains("rotated:"));
749        let parsed: Member = serde_yaml_ng::from_str(&yaml).unwrap();
750        assert_eq!(m.ai_delegations["ai:claude@joy"].rotated, Some(rotated));
751        assert_eq!(parsed, m);
752    }
753
754    // -----------------------------------------------------------------------
755    // attestation (JOY-00FA-A5) tests
756    // -----------------------------------------------------------------------
757
758    #[test]
759    fn attestation_omitted_when_none() {
760        let m = Member::new(MemberCapabilities::All);
761        let yaml = serde_yaml_ng::to_string(&m).unwrap();
762        assert!(!yaml.contains("attestation:"));
763    }
764
765    #[test]
766    fn attestation_yaml_roundtrips() {
767        let mut m = Member::new(MemberCapabilities::All);
768        m.attestation = Some(Attestation {
769            attester: "horst@example.com".into(),
770            signed_fields: AttestationSignedFields {
771                email: "alice@example.com".into(),
772                capabilities: MemberCapabilities::All,
773                enrollment_verifier: Some("ff".repeat(32)),
774            },
775            signed_at: chrono::DateTime::parse_from_rfc3339("2026-04-20T10:00:00Z")
776                .unwrap()
777                .with_timezone(&chrono::Utc),
778            signature: "aa".repeat(32),
779        });
780        let yaml = serde_yaml_ng::to_string(&m).unwrap();
781        assert!(yaml.contains("attestation:"));
782        assert!(yaml.contains("attester: horst@example.com"));
783        let parsed: Member = serde_yaml_ng::from_str(&yaml).unwrap();
784        assert_eq!(parsed, m);
785    }
786
787    #[test]
788    fn attestation_signed_fields_canonical_is_deterministic() {
789        let a = AttestationSignedFields {
790            email: "alice@example.com".into(),
791            capabilities: MemberCapabilities::All,
792            enrollment_verifier: Some("abc".into()),
793        };
794        let b = a.clone();
795        assert_eq!(a.canonical_bytes(), b.canonical_bytes());
796    }
797
798    #[test]
799    fn attestation_signed_fields_differ_on_capability_change() {
800        let a = AttestationSignedFields {
801            email: "alice@example.com".into(),
802            capabilities: MemberCapabilities::All,
803            enrollment_verifier: None,
804        };
805        let mut caps = BTreeMap::new();
806        caps.insert(Capability::Implement, CapabilityConfig::default());
807        let b = AttestationSignedFields {
808            email: "alice@example.com".into(),
809            capabilities: MemberCapabilities::Specific(caps),
810            enrollment_verifier: None,
811        };
812        assert_ne!(a.canonical_bytes(), b.canonical_bytes());
813    }
814
815    #[test]
816    fn unknown_fields_from_legacy_yaml_are_ignored() {
817        // project.yaml files written by older Joy versions may still carry
818        // ai_tokens entries. They are silently discarded by serde default
819        // behaviour and do not block deserialisation.
820        let yaml = r#"
821capabilities: all
822public_key: aa
823salt: bb
824ai_tokens:
825  ai:claude@joy:
826    token_key: oldkey
827    created: "2026-03-28T22:00:00Z"
828ai_delegations:
829  ai:claude@joy:
830    delegation_verifier: newkey
831    created: "2026-04-15T10:00:00Z"
832"#;
833        let parsed: Member = serde_yaml_ng::from_str(yaml).unwrap();
834        assert_eq!(
835            parsed.ai_delegations["ai:claude@joy"].delegation_verifier,
836            "newkey"
837        );
838    }
839
840    // -----------------------------------------------------------------------
841    // Docs tests
842    // -----------------------------------------------------------------------
843
844    #[test]
845    fn docs_defaults_when_unset() {
846        let docs = Docs::default();
847        assert_eq!(docs.architecture_or_default(), Docs::DEFAULT_ARCHITECTURE);
848        assert_eq!(docs.vision_or_default(), Docs::DEFAULT_VISION);
849        assert_eq!(docs.contributing_or_default(), Docs::DEFAULT_CONTRIBUTING);
850    }
851
852    #[test]
853    fn docs_returns_configured_value() {
854        let docs = Docs {
855            architecture: Some("ARCHITECTURE.md".into()),
856            vision: Some("docs/product/vision.md".into()),
857            contributing: None,
858        };
859        assert_eq!(docs.architecture_or_default(), "ARCHITECTURE.md");
860        assert_eq!(docs.vision_or_default(), "docs/product/vision.md");
861        assert_eq!(docs.contributing_or_default(), Docs::DEFAULT_CONTRIBUTING);
862    }
863
864    #[test]
865    fn docs_omitted_from_yaml_when_empty() {
866        let project = Project::new("X".into(), None);
867        let yaml = serde_yaml_ng::to_string(&project).unwrap();
868        assert!(
869            !yaml.contains("docs:"),
870            "empty docs should be skipped, got: {yaml}"
871        );
872    }
873
874    #[test]
875    fn docs_present_in_yaml_when_set() {
876        let mut project = Project::new("X".into(), None);
877        project.docs.architecture = Some("ARCHITECTURE.md".into());
878        let yaml = serde_yaml_ng::to_string(&project).unwrap();
879        assert!(yaml.contains("docs:"), "docs block expected: {yaml}");
880        assert!(yaml.contains("architecture: ARCHITECTURE.md"));
881        assert!(!yaml.contains("vision:"), "unset fields should be skipped");
882    }
883
884    #[test]
885    fn docs_yaml_roundtrip_with_overrides() {
886        let yaml = r#"
887name: Existing
888language: en
889docs:
890  architecture: ARCHITECTURE.md
891  contributing: docs/CONTRIBUTING.md
892created: 2026-01-01T00:00:00Z
893"#;
894        let parsed: Project = serde_yaml_ng::from_str(yaml).unwrap();
895        assert_eq!(parsed.docs.architecture.as_deref(), Some("ARCHITECTURE.md"));
896        assert_eq!(parsed.docs.vision, None);
897        assert_eq!(
898            parsed.docs.contributing.as_deref(),
899            Some("docs/CONTRIBUTING.md")
900        );
901        assert_eq!(parsed.docs.vision_or_default(), Docs::DEFAULT_VISION);
902    }
903
904    #[test]
905    fn derive_acronym_multi_word() {
906        assert_eq!(derive_acronym("My Cool Project"), "MCP");
907    }
908
909    #[test]
910    fn derive_acronym_single_word() {
911        assert_eq!(derive_acronym("Joy"), "JOY");
912    }
913
914    #[test]
915    fn derive_acronym_long_name() {
916        assert_eq!(derive_acronym("A Very Long Project Name"), "AVLP");
917    }
918
919    #[test]
920    fn derive_acronym_single_long_word() {
921        assert_eq!(derive_acronym("Platform"), "PLA");
922    }
923
924    // -----------------------------------------------------------------------
925    // validate_acronym tests
926    // -----------------------------------------------------------------------
927
928    #[test]
929    fn validate_acronym_accepts_real_project_acronyms() {
930        for a in ["JI", "JOT", "JOY", "JON", "JP", "JAPP", "JOYC", "JISITE"] {
931            assert_eq!(validate_acronym(a).unwrap(), a, "rejected real acronym {a}");
932        }
933    }
934
935    #[test]
936    fn validate_acronym_accepts_alphanumeric() {
937        assert_eq!(validate_acronym("V2").unwrap(), "V2");
938        assert_eq!(validate_acronym("A1B2").unwrap(), "A1B2");
939    }
940
941    #[test]
942    fn validate_acronym_normalizes_case_and_whitespace() {
943        assert_eq!(validate_acronym("jyn").unwrap(), "JYN");
944        assert_eq!(validate_acronym("Jyn").unwrap(), "JYN");
945        assert_eq!(validate_acronym("  jyn  ").unwrap(), "JYN");
946    }
947
948    #[test]
949    fn validate_acronym_rejects_too_short() {
950        assert!(validate_acronym("").is_err());
951        assert!(validate_acronym("J").is_err());
952        assert!(validate_acronym(" J ").is_err());
953    }
954
955    #[test]
956    fn validate_acronym_rejects_too_long() {
957        assert!(validate_acronym("ABCDEFGHI").is_err());
958    }
959
960    #[test]
961    fn validate_acronym_rejects_non_alnum() {
962        assert!(validate_acronym("JY-N").is_err());
963        assert!(validate_acronym("JY N").is_err());
964        assert!(validate_acronym("JY_N").is_err());
965        assert!(validate_acronym("JY.N").is_err());
966    }
967
968    #[test]
969    fn validate_acronym_rejects_non_ascii() {
970        assert!(validate_acronym("AEBC").is_ok());
971        assert!(validate_acronym("ABC").is_ok());
972        assert!(validate_acronym("\u{00c4}BC").is_err());
973    }
974
975    // -----------------------------------------------------------------------
976    // ModeDefaults deserialization tests
977    // -----------------------------------------------------------------------
978
979    #[test]
980    fn mode_defaults_flat_yaml_roundtrip() {
981        let yaml = r#"
982default: interactive
983implement: collaborative
984review: pairing
985"#;
986        let parsed: ModeDefaults = serde_yaml_ng::from_str(yaml).unwrap();
987        assert_eq!(parsed.default, InteractionLevel::Interactive);
988        assert_eq!(
989            parsed.capabilities[&Capability::Implement],
990            InteractionLevel::Collaborative
991        );
992        assert_eq!(
993            parsed.capabilities[&Capability::Review],
994            InteractionLevel::Pairing
995        );
996    }
997
998    #[test]
999    fn mode_defaults_empty_yaml() {
1000        let yaml = "{}";
1001        let parsed: ModeDefaults = serde_yaml_ng::from_str(yaml).unwrap();
1002        assert_eq!(parsed.default, InteractionLevel::Collaborative);
1003        assert!(parsed.capabilities.is_empty());
1004    }
1005
1006    #[test]
1007    fn mode_defaults_only_default() {
1008        let yaml = "default: pairing";
1009        let parsed: ModeDefaults = serde_yaml_ng::from_str(yaml).unwrap();
1010        assert_eq!(parsed.default, InteractionLevel::Pairing);
1011        assert!(parsed.capabilities.is_empty());
1012    }
1013
1014    #[test]
1015    fn ai_defaults_yaml_roundtrip() {
1016        let yaml = r#"
1017capabilities:
1018  - implement
1019  - review
1020"#;
1021        let parsed: AiDefaults = serde_yaml_ng::from_str(yaml).unwrap();
1022        assert_eq!(parsed.capabilities.len(), 2);
1023        assert_eq!(parsed.capabilities[0], Capability::Implement);
1024    }
1025
1026    // -----------------------------------------------------------------------
1027    // resolve_mode tests
1028    // -----------------------------------------------------------------------
1029
1030    fn defaults_with_mode(mode: InteractionLevel) -> ModeDefaults {
1031        ModeDefaults {
1032            default: mode,
1033            ..Default::default()
1034        }
1035    }
1036
1037    fn defaults_with_cap_mode(cap: Capability, mode: InteractionLevel) -> ModeDefaults {
1038        let mut d = ModeDefaults::default();
1039        d.capabilities.insert(cap, mode);
1040        d
1041    }
1042
1043    #[test]
1044    fn resolve_mode_uses_global_default() {
1045        let raw = defaults_with_mode(InteractionLevel::Collaborative);
1046        let effective = raw.clone();
1047        let (mode, source) = resolve_mode(&Capability::Implement, &raw, &effective, None, None);
1048        assert_eq!(mode, InteractionLevel::Collaborative);
1049        assert_eq!(source, ModeSource::Default);
1050    }
1051
1052    #[test]
1053    fn resolve_mode_uses_per_capability_default() {
1054        let raw = defaults_with_cap_mode(Capability::Review, InteractionLevel::Interactive);
1055        let effective = raw.clone();
1056        let (mode, source) = resolve_mode(&Capability::Review, &raw, &effective, None, None);
1057        assert_eq!(mode, InteractionLevel::Interactive);
1058        assert_eq!(source, ModeSource::Default);
1059    }
1060
1061    #[test]
1062    fn resolve_mode_project_override_detected() {
1063        let raw = defaults_with_cap_mode(Capability::Implement, InteractionLevel::Collaborative);
1064        let effective =
1065            defaults_with_cap_mode(Capability::Implement, InteractionLevel::Interactive);
1066        let (mode, source) = resolve_mode(&Capability::Implement, &raw, &effective, None, None);
1067        assert_eq!(mode, InteractionLevel::Interactive);
1068        assert_eq!(source, ModeSource::Project);
1069    }
1070
1071    #[test]
1072    fn resolve_mode_personal_overrides_default() {
1073        let raw = defaults_with_mode(InteractionLevel::Collaborative);
1074        let effective = raw.clone();
1075        let (mode, source) = resolve_mode(
1076            &Capability::Implement,
1077            &raw,
1078            &effective,
1079            Some(InteractionLevel::Pairing),
1080            None,
1081        );
1082        assert_eq!(mode, InteractionLevel::Pairing);
1083        assert_eq!(source, ModeSource::Personal);
1084    }
1085
1086    #[test]
1087    fn resolve_mode_max_mode_clamps_upward() {
1088        let raw = defaults_with_mode(InteractionLevel::Autonomous);
1089        let effective = raw.clone();
1090        let cap_config = CapabilityConfig {
1091            max_mode: Some(InteractionLevel::Supervised),
1092            ..Default::default()
1093        };
1094        let (mode, source) = resolve_mode(
1095            &Capability::Implement,
1096            &raw,
1097            &effective,
1098            None,
1099            Some(&cap_config),
1100        );
1101        assert_eq!(mode, InteractionLevel::Supervised);
1102        assert_eq!(source, ModeSource::ProjectMax);
1103    }
1104
1105    #[test]
1106    fn resolve_mode_max_mode_does_not_lower() {
1107        let raw = defaults_with_mode(InteractionLevel::Pairing);
1108        let effective = raw.clone();
1109        let cap_config = CapabilityConfig {
1110            max_mode: Some(InteractionLevel::Supervised),
1111            ..Default::default()
1112        };
1113        let (mode, source) = resolve_mode(
1114            &Capability::Implement,
1115            &raw,
1116            &effective,
1117            None,
1118            Some(&cap_config),
1119        );
1120        // Pairing > Supervised, so no clamping
1121        assert_eq!(mode, InteractionLevel::Pairing);
1122        assert_eq!(source, ModeSource::Default);
1123    }
1124
1125    #[test]
1126    fn resolve_mode_personal_clamped_by_max() {
1127        let raw = defaults_with_mode(InteractionLevel::Collaborative);
1128        let effective = raw.clone();
1129        let cap_config = CapabilityConfig {
1130            max_mode: Some(InteractionLevel::Interactive),
1131            ..Default::default()
1132        };
1133        let (mode, source) = resolve_mode(
1134            &Capability::Implement,
1135            &raw,
1136            &effective,
1137            Some(InteractionLevel::Autonomous),
1138            Some(&cap_config),
1139        );
1140        // Personal is Autonomous but max is Interactive, clamp up
1141        assert_eq!(mode, InteractionLevel::Interactive);
1142        assert_eq!(source, ModeSource::ProjectMax);
1143    }
1144
1145    // -----------------------------------------------------------------------
1146    // Item mode serialization
1147    // -----------------------------------------------------------------------
1148
1149    #[test]
1150    fn item_mode_field_roundtrip() {
1151        use crate::model::item::{Item, ItemType, Priority};
1152
1153        let mut item = Item::new(
1154            "TST-0001".into(),
1155            "Test".into(),
1156            ItemType::Task,
1157            Priority::Medium,
1158            vec![],
1159        );
1160        item.mode = Some(InteractionLevel::Pairing);
1161
1162        let yaml = serde_yaml_ng::to_string(&item).unwrap();
1163        assert!(yaml.contains("mode: pairing"), "mode field not serialized");
1164
1165        let parsed: Item = serde_yaml_ng::from_str(&yaml).unwrap();
1166        assert_eq!(parsed.mode, Some(InteractionLevel::Pairing));
1167    }
1168
1169    #[test]
1170    fn item_mode_field_absent_when_none() {
1171        use crate::model::item::{Item, ItemType, Priority};
1172
1173        let item = Item::new(
1174            "TST-0002".into(),
1175            "Test".into(),
1176            ItemType::Task,
1177            Priority::Medium,
1178            vec![],
1179        );
1180        assert_eq!(item.mode, None);
1181
1182        let yaml = serde_yaml_ng::to_string(&item).unwrap();
1183        assert!(
1184            !yaml.contains("mode:"),
1185            "mode field should not appear when None"
1186        );
1187    }
1188
1189    #[test]
1190    fn item_mode_deserialized_from_existing_yaml() {
1191        let yaml = r#"
1192id: TST-0003
1193title: Test
1194type: task
1195status: new
1196priority: medium
1197mode: interactive
1198created: "2026-01-01T00:00:00+00:00"
1199updated: "2026-01-01T00:00:00+00:00"
1200"#;
1201        let item: crate::model::item::Item = serde_yaml_ng::from_str(yaml).unwrap();
1202        assert_eq!(item.mode, Some(InteractionLevel::Interactive));
1203    }
1204
1205    // -----------------------------------------------------------------------
1206    // Full four-layer resolution scenario
1207    // -----------------------------------------------------------------------
1208
1209    #[test]
1210    fn resolve_mode_full_scenario() {
1211        // Joy default: implement = collaborative
1212        let raw = defaults_with_cap_mode(Capability::Implement, InteractionLevel::Collaborative);
1213        // Project override: implement = interactive
1214        let effective =
1215            defaults_with_cap_mode(Capability::Implement, InteractionLevel::Interactive);
1216        // Personal preference: autonomous
1217        let personal = Some(InteractionLevel::Autonomous);
1218        // Project max-mode: supervised (minimum interactivity)
1219        let cap_config = CapabilityConfig {
1220            max_mode: Some(InteractionLevel::Supervised),
1221            ..Default::default()
1222        };
1223
1224        let (mode, source) = resolve_mode(
1225            &Capability::Implement,
1226            &raw,
1227            &effective,
1228            personal,
1229            Some(&cap_config),
1230        );
1231
1232        // Personal (autonomous) < max (supervised), so clamped up to supervised
1233        assert_eq!(mode, InteractionLevel::Supervised);
1234        assert_eq!(source, ModeSource::ProjectMax);
1235    }
1236
1237    #[test]
1238    fn resolve_mode_all_layers_no_clamping() {
1239        // Joy default: implement = collaborative
1240        let raw = defaults_with_cap_mode(Capability::Implement, InteractionLevel::Collaborative);
1241        // Project override: implement = interactive
1242        let effective =
1243            defaults_with_cap_mode(Capability::Implement, InteractionLevel::Interactive);
1244        // Personal preference: pairing (more interactive than project)
1245        let personal = Some(InteractionLevel::Pairing);
1246        // No max-mode
1247        let cap_config = CapabilityConfig::default();
1248
1249        let (mode, source) = resolve_mode(
1250            &Capability::Implement,
1251            &raw,
1252            &effective,
1253            personal,
1254            Some(&cap_config),
1255        );
1256
1257        // Personal wins, no clamping
1258        assert_eq!(mode, InteractionLevel::Pairing);
1259        assert_eq!(source, ModeSource::Personal);
1260    }
1261}