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#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct TeamAnnotationNote {
12 pub author: String,
13 pub date: String,
14 pub note: String,
15 #[serde(default, skip_serializing_if = "Option::is_none")]
17 pub severity: Option<String>,
18}
19
20#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct TeamAnnotation {
28 pub id: String,
29 #[serde(default)]
31 pub notes: Vec<TeamAnnotationNote>,
32 #[serde(default)]
34 pub issues: Vec<TeamAnnotationNote>,
35 #[serde(default)]
37 pub fixes: Vec<TeamAnnotationNote>,
38 #[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
52pub 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
63pub 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
125pub 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
134pub 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
162fn 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
197pub 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
230pub 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
265pub 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}