Skip to main content

chub_core/team/
team_annotations.rs

1use std::fs;
2use std::path::PathBuf;
3
4use serde::{Deserialize, Serialize};
5
6use crate::annotations::AnnotationKind;
7use crate::team::project::project_chub_dir;
8
9/// A single annotation note (used across notes/issues/fixes/practices sections).
10#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct TeamAnnotationNote {
12    pub author: String,
13    pub date: String,
14    pub note: String,
15    /// Severity level — only meaningful for issues (high | medium | low).
16    #[serde(default, skip_serializing_if = "Option::is_none")]
17    pub severity: Option<String>,
18}
19
20/// A team annotation file (`.chub/annotations/<id>.yaml`).
21///
22/// **Append semantics**: each `write_team_annotation()` call adds a new entry to the
23/// appropriate section. Entries are never replaced — use `clear_team_annotation()` to
24/// remove the entire file. Unlike personal annotations (which overwrite), team annotations
25/// maintain a full history with author and date for each entry.
26#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct TeamAnnotation {
28    pub id: String,
29    /// General notes (kind=note, backward-compatible).
30    #[serde(default)]
31    pub notes: Vec<TeamAnnotationNote>,
32    /// Known bugs, broken params, misleading examples (kind=issue).
33    #[serde(default)]
34    pub issues: Vec<TeamAnnotationNote>,
35    /// Workarounds that resolve issues (kind=fix).
36    #[serde(default)]
37    pub fixes: Vec<TeamAnnotationNote>,
38    /// Team conventions and validated patterns (kind=practice).
39    #[serde(default)]
40    pub practices: Vec<TeamAnnotationNote>,
41}
42
43fn team_annotations_dir() -> Option<PathBuf> {
44    project_chub_dir().map(|d| d.join("annotations"))
45}
46
47fn team_annotation_path(entry_id: &str) -> Option<PathBuf> {
48    let safe = crate::util::sanitize_entry_id(entry_id);
49    team_annotations_dir().map(|d| d.join(format!("{}.yaml", safe)))
50}
51
52/// Read team annotations for an entry.
53pub fn read_team_annotation(entry_id: &str) -> Option<TeamAnnotation> {
54    let path = team_annotation_path(entry_id)?;
55    if !path.exists() {
56        return None;
57    }
58    fs::read_to_string(&path)
59        .ok()
60        .and_then(|s| serde_yaml::from_str(&s).ok())
61}
62
63/// Write a team annotation (append a note to the appropriate section).
64/// Severity is only stored when kind=Issue; it is ignored for other kinds.
65pub fn write_team_annotation(
66    entry_id: &str,
67    note: &str,
68    author: &str,
69    kind: AnnotationKind,
70    severity: Option<String>,
71) -> Option<TeamAnnotation> {
72    let dir = team_annotations_dir()?;
73    if let Err(e) = fs::create_dir_all(&dir) {
74        eprintln!("Warning: failed to create annotations dir: {}", e);
75        return None;
76    }
77
78    let mut ann = read_team_annotation(entry_id).unwrap_or(TeamAnnotation {
79        id: entry_id.to_string(),
80        notes: vec![],
81        issues: vec![],
82        fixes: vec![],
83        practices: vec![],
84    });
85
86    let date = crate::util::today_date();
87
88    let entry = TeamAnnotationNote {
89        author: author.to_string(),
90        date,
91        note: crate::annotations::sanitize_note(note),
92        severity: if kind == AnnotationKind::Issue {
93            severity
94        } else {
95            None
96        },
97    };
98
99    match kind {
100        AnnotationKind::Issue => ann.issues.push(entry),
101        AnnotationKind::Fix => ann.fixes.push(entry),
102        AnnotationKind::Practice => ann.practices.push(entry),
103        AnnotationKind::Note => ann.notes.push(entry),
104    }
105
106    let path = team_annotation_path(entry_id)?;
107    let yaml = match serde_yaml::to_string(&ann) {
108        Ok(y) => y,
109        Err(e) => {
110            eprintln!("Warning: failed to serialize annotation: {}", e);
111            return None;
112        }
113    };
114    if let Err(e) = crate::util::atomic_write(&path, yaml.as_bytes()) {
115        eprintln!(
116            "Warning: failed to write annotation to {}: {}",
117            path.display(),
118            e
119        );
120        return None;
121    }
122    Some(ann)
123}
124
125/// Delete the entire team annotation file for an entry.
126/// Returns true if a file was removed, false if it didn't exist.
127pub fn clear_team_annotation(entry_id: &str) -> bool {
128    match team_annotation_path(entry_id) {
129        Some(path) => fs::remove_file(path).is_ok(),
130        None => false,
131    }
132}
133
134/// List all team annotations.
135pub fn list_team_annotations() -> Vec<TeamAnnotation> {
136    let dir = match team_annotations_dir() {
137        Some(d) if d.exists() => d,
138        _ => return vec![],
139    };
140
141    let files = match fs::read_dir(&dir) {
142        Ok(entries) => entries,
143        Err(_) => return vec![],
144    };
145
146    files
147        .filter_map(|e| e.ok())
148        .filter(|e| {
149            e.path()
150                .extension()
151                .map(|ext| ext == "yaml" || ext == "yml")
152                .unwrap_or(false)
153        })
154        .filter_map(|e| {
155            fs::read_to_string(e.path())
156                .ok()
157                .and_then(|s| serde_yaml::from_str::<TeamAnnotation>(&s).ok())
158        })
159        .collect()
160}
161
162/// Format a TeamAnnotation's entries as display strings with a given tier label prefix.
163fn format_tier_parts(ann: &TeamAnnotation, tier_label: &str) -> Vec<String> {
164    let mut parts = Vec::new();
165    for note in &ann.issues {
166        let sev = note
167            .severity
168            .as_deref()
169            .map(|s| format!(" ({})", s))
170            .unwrap_or_default();
171        parts.push(format!(
172            "[{} issue{} — {} ({})] {}",
173            tier_label, sev, note.author, note.date, note.note
174        ));
175    }
176    for note in &ann.fixes {
177        parts.push(format!(
178            "[{} fix — {} ({})] {}",
179            tier_label, note.author, note.date, note.note
180        ));
181    }
182    for note in &ann.practices {
183        parts.push(format!(
184            "[{} practice — {} ({})] {}",
185            tier_label, note.author, note.date, note.note
186        ));
187    }
188    for note in &ann.notes {
189        parts.push(format!(
190            "[{} — {} ({})] {}",
191            tier_label, note.author, note.date, note.note
192        ));
193    }
194    parts
195}
196
197/// Merge team + personal annotations into a display string, grouped by kind.
198/// Team annotations are shown first (issues → fixes → practices → notes),
199/// followed by any personal annotation.
200pub fn get_merged_annotation(entry_id: &str) -> Option<String> {
201    let team = read_team_annotation(entry_id);
202    let personal = crate::annotations::read_annotation(entry_id);
203
204    let mut parts = Vec::new();
205
206    if let Some(ref ann) = team {
207        parts.extend(format_tier_parts(ann, "Team"));
208    }
209
210    if let Some(ref p) = personal {
211        let kind_tag = p.kind.as_str();
212        let sev = p
213            .severity
214            .as_deref()
215            .map(|s| format!(" ({})", s))
216            .unwrap_or_default();
217        parts.push(format!(
218            "[Personal {}{} — {}] {}",
219            kind_tag, sev, p.updated_at, p.note
220        ));
221    }
222
223    if parts.is_empty() {
224        None
225    } else {
226        Some(parts.join("\n"))
227    }
228}
229
230/// Merge all three tiers: Org baseline → Team overlay → Personal wins.
231/// Falls back gracefully if Tier 3 is not configured or unreachable.
232pub async fn get_merged_annotation_async(entry_id: &str) -> Option<String> {
233    let org = crate::team::org_annotations::read_org_annotation(entry_id).await;
234    let team = read_team_annotation(entry_id);
235    let personal = crate::annotations::read_annotation(entry_id);
236
237    let mut parts = Vec::new();
238
239    if let Some(ref ann) = org {
240        parts.extend(format_tier_parts(ann, "Org"));
241    }
242    if let Some(ref ann) = team {
243        parts.extend(format_tier_parts(ann, "Team"));
244    }
245    if let Some(ref p) = personal {
246        let kind_tag = p.kind.as_str();
247        let sev = p
248            .severity
249            .as_deref()
250            .map(|s| format!(" ({})", s))
251            .unwrap_or_default();
252        parts.push(format!(
253            "[Personal {}{} — {}] {}",
254            kind_tag, sev, p.updated_at, p.note
255        ));
256    }
257
258    if parts.is_empty() {
259        None
260    } else {
261        Some(parts.join("\n"))
262    }
263}
264
265/// Generate the notice appended to a pinned doc when it is served.
266pub fn get_pin_notice(
267    pinned_version: Option<&str>,
268    pinned_lang: Option<&str>,
269    reason: Option<&str>,
270) -> String {
271    let mut notice = String::from("\n---\n[Team pin]");
272    if let Some(ver) = pinned_version {
273        notice.push_str(&format!(" Locked to v{}", ver));
274    }
275    if let Some(lang) = pinned_lang {
276        notice.push_str(&format!(" ({})", lang));
277    }
278    notice.push('.');
279    if let Some(reason) = reason {
280        notice.push_str(&format!(" Reason: {}", reason));
281    }
282    notice.push('\n');
283    notice
284}