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 = "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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
43pub struct AiTokenEntry {
44 pub token_key: String,
46 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#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
75pub struct ModeDefaults {
76 #[serde(default)]
78 pub default: InteractionLevel,
79 #[serde(flatten, default)]
81 pub capabilities: BTreeMap<Capability, InteractionLevel>,
82}
83
84#[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
94pub enum ModeSource {
95 Default,
97 Project,
99 Personal,
101 Item,
103 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
119pub 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 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 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 if let Some(personal) = personal_mode {
155 mode = personal;
156 source = ModeSource::Personal;
157 }
158
159 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
172impl 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 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 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
220pub 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
243pub 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 #[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 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 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 assert_eq!(mode, InteractionLevel::Interactive);
465 assert_eq!(source, ModeSource::ProjectMax);
466 }
467
468 #[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 #[test]
533 fn resolve_mode_full_scenario() {
534 let raw = defaults_with_cap_mode(Capability::Implement, InteractionLevel::Collaborative);
536 let effective =
538 defaults_with_cap_mode(Capability::Implement, InteractionLevel::Interactive);
539 let personal = Some(InteractionLevel::Autonomous);
541 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 assert_eq!(mode, InteractionLevel::Supervised);
557 assert_eq!(source, ModeSource::ProjectMax);
558 }
559
560 #[test]
561 fn resolve_mode_all_layers_no_clamping() {
562 let raw = defaults_with_cap_mode(Capability::Implement, InteractionLevel::Collaborative);
564 let effective =
566 defaults_with_cap_mode(Capability::Implement, InteractionLevel::Interactive);
567 let personal = Some(InteractionLevel::Pairing);
569 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 assert_eq!(mode, InteractionLevel::Pairing);
582 assert_eq!(source, ModeSource::Personal);
583 }
584}