1use 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#[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 pub fn architecture_or_default(&self) -> &str {
54 self.architecture
55 .as_deref()
56 .unwrap_or(Self::DEFAULT_ARCHITECTURE)
57 }
58
59 pub fn vision_or_default(&self) -> &str {
61 self.vision.as_deref().unwrap_or(Self::DEFAULT_VISION)
62 }
63
64 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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
87pub struct AiTokenEntry {
88 pub token_key: String,
90 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#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
119pub struct ModeDefaults {
120 #[serde(default)]
122 pub default: InteractionLevel,
123 #[serde(flatten, default)]
125 pub capabilities: BTreeMap<Capability, InteractionLevel>,
126}
127
128#[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
138pub enum ModeSource {
139 Default,
141 Project,
143 Personal,
145 Item,
147 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
163pub 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 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 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 if let Some(personal) = personal_mode {
199 mode = personal;
200 source = ModeSource::Personal;
201 }
202
203 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
216impl 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 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 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
264pub 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
288pub 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 #[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 #[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 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 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 assert_eq!(mode, InteractionLevel::Interactive);
574 assert_eq!(source, ModeSource::ProjectMax);
575 }
576
577 #[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 #[test]
642 fn resolve_mode_full_scenario() {
643 let raw = defaults_with_cap_mode(Capability::Implement, InteractionLevel::Collaborative);
645 let effective =
647 defaults_with_cap_mode(Capability::Implement, InteractionLevel::Interactive);
648 let personal = Some(InteractionLevel::Autonomous);
650 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 assert_eq!(mode, InteractionLevel::Supervised);
666 assert_eq!(source, ModeSource::ProjectMax);
667 }
668
669 #[test]
670 fn resolve_mode_all_layers_no_clamping() {
671 let raw = defaults_with_cap_mode(Capability::Implement, InteractionLevel::Collaborative);
673 let effective =
675 defaults_with_cap_mode(Capability::Implement, InteractionLevel::Interactive);
676 let personal = Some(InteractionLevel::Pairing);
678 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 assert_eq!(mode, InteractionLevel::Pairing);
691 assert_eq!(source, ModeSource::Personal);
692 }
693}