1use super::*;
2
3#[derive(Debug, Clone)]
4pub(super) struct ArchivedCaseEntry {
5 pub(super) case_uid: String,
6 pub(super) path: PathBuf,
7}
8
9pub(super) fn case_name(case_path: &Path) -> Result<String> {
10 Ok(read_case_file(case_path)?.collection_name)
11}
12
13pub(super) fn update_case_name(case_path: &Path, case_name: &str) -> Result<()> {
14 let mut case = read_case_file(case_path)?;
15 case.collection_name = case_name.to_string();
16 case.updated_rfc3339 = Some(now_rfc3339());
17 write_case_file(case_path, &case)
18}
19
20pub(super) fn update_case_archive_state(case_path: &Path, status: &str) -> Result<()> {
21 let mut case = read_case_file(case_path)?;
22 case.status = status.to_string();
23 case.updated_rfc3339 = Some(now_rfc3339());
24 if status == "archived" {
25 case.archived_rfc3339.get_or_insert_with(now_rfc3339);
26 } else {
27 case.archived_rfc3339 = None;
28 }
29 write_case_file(case_path, &case)
30}
31
32pub(super) fn new_case_file(
33 case_uid: &str,
34 case_name: &str,
35 items: Vec<MessageCollectionItem>,
36) -> CaseFrontmatter {
37 let now = now_rfc3339();
38 let mut case = CaseFrontmatter::new_case(case_uid, case_name, &now);
39 case.items = items;
40 case.message_count = case.items.len();
41 if !case.items.is_empty() {
42 case.last_message_rfc3339 = Some(now);
43 }
44 case
45}
46
47pub(super) fn new_notes_md(root: &Path, language: TemplateLanguage) -> Result<String> {
48 render_template(
49 root,
50 language,
51 TemplateKey::NotesDefault,
52 &json!({"language": language.as_str()}),
53 )
54}
55
56pub(super) fn merge_case_notes(
57 root: &Path,
58 language: TemplateLanguage,
59 case_uid: &str,
60 primary: &Path,
61 other: &Path,
62 other_case_uid: &str,
63) -> Result<()> {
64 let other_notes_path = other.join("notes.md");
65 if !other_notes_path.exists() {
66 return Ok(());
67 }
68 let other_notes = read_to_string(&other_notes_path, "read merged notes.md")?;
69 if other_notes.trim().is_empty() {
70 return Ok(());
71 }
72 let primary_notes_path = primary.join("notes.md");
73 if !primary_notes_path.exists() {
74 return Err(notes_missing_error(root, &primary_notes_path));
75 }
76 let existing = read_to_string(&primary_notes_path, "read primary notes.md")?;
77 let section = render_template(
78 root,
79 language,
80 TemplateKey::NotesMergeSection,
81 &json!({
82 "language": language.as_str(),
83 "case_uid": case_uid,
84 "other_case_uid": other_case_uid,
85 "other_body": other_notes,
86 }),
87 )?;
88 let merged = format!("{}\n\n{}", existing.trim_end(), section.trim_start());
89 write_string(&primary_notes_path, &merged)
90}
91
92pub(super) fn update_case_counts(
93 case: &mut CaseFrontmatter,
94 added_ids: &[String],
95 attachment_count: Option<usize>,
96) {
97 case.message_count += added_ids.len();
98 case.updated_rfc3339 = Some(now_rfc3339());
99 if let Some(count) = attachment_count {
100 case.attachment_count = count;
101 }
102}
103
104pub(super) fn read_case_messages(case_path: &Path, case_uid: &str) -> Result<CaseMessages> {
105 let messages = read_case_file(case_path)?;
106 if messages.collection_uid != case_uid {
107 return Err(AppError::new(
108 "case_messages_invalid",
109 format!(
110 "invalid case uid in metadata: {}",
111 path_to_string(&case_json_path(case_path))
112 ),
113 ));
114 }
115 Ok(messages)
116}
117
118pub(super) fn existing_triage_suggestion(path: &Path) -> Result<(Vec<String>, Option<String>)> {
119 if !path.exists() {
120 return Ok((Vec::new(), None));
121 }
122 let text = read_to_string(path, "read triage file")?;
123 let (fm, _) = read_doc::<TriageFrontmatter>(&text)?;
124 if fm.suggested_case_uids.is_empty() {
125 return Ok((Vec::new(), None));
126 }
127 Ok((fm.suggested_case_uids, fm.suggested_reason))
128}
129
130impl Workspace {
131 pub fn create_case(
132 &self,
133 name: &str,
134 group: Option<&str>,
135 message_id: Option<&str>,
136 summary: Option<&str>,
137 reason: Option<&str>,
138 ) -> Result<Value> {
139 self.require_workspace()?;
140 let reason = if message_id.is_some() {
141 self.checked_reason(reason)?
142 } else {
143 reason.map(str::trim).filter(|value| !value.is_empty())
144 };
145 validate_name("case_name", name)?;
146 if let Some(message_id) = message_id {
147 validate_id("message_id", message_id)?;
148 }
149 let config = MailConfig::load(&self.root)?;
150 let group = group.unwrap_or(config.case.default_group.as_str());
151 validate_id("group", group)?;
152 let date = if let Some(message_id) = message_id {
153 self.first_related_message_date(message_id)?
154 } else {
155 workspace_local_date(&config.resolved_timezone_offset())
156 };
157 let case_uid = self.next_case_uid(&date)?;
158 let case_path = self
159 .root
160 .join("cases")
161 .join(group)
162 .join(case_dir_name(&case_uid, name));
163 if case_path.exists() {
164 return Err(AppError::new(
165 "case_exists",
166 format!("case path already exists: {}", path_to_string(&case_path)),
167 ));
168 }
169 create_dir_all(&case_data_dir(&case_path))?;
170 create_dir_all(&case_views_messages_dir(&case_path))?;
171 create_dir_all(&case_path.join("drafts"))?;
172 create_dir_all(&case_path.join("files"))?;
173 let added_rfc3339 = now_rfc3339();
174 let items = message_id
175 .map(|message_id| {
176 vec![MessageCollectionItem {
177 message_id: message_id.to_string(),
178 summary: summary
179 .or(reason)
180 .map(str::trim)
181 .filter(|value| !value.is_empty())
182 .map(ToString::to_string),
183 added_rfc3339: added_rfc3339.clone(),
184 }]
185 })
186 .unwrap_or_default();
187 let case_data = new_case_file(&case_uid, name, items);
188 write_case_file(&case_path, &case_data)?;
189 write_string_new(
190 &case_path.join("notes.md"),
191 &new_notes_md(&self.root, config.template_language())?,
192 )?;
193 let message_ids = case_data.message_ids();
194 if !message_ids.is_empty() {
195 for message_id in &message_ids {
196 self.clear_message_from_all_dispositions(message_id)?;
197 }
198 self.refresh_messages_after_ref_change(&message_ids)?;
199 }
200 self.refresh_case_message_views(&case_path)?;
201 let mut result = json!({
202 "code": "case_created",
203 "case_uid": case_uid,
204 "case_name": name,
205 "group": group,
206 "message_ids": message_ids,
207 "message_count": case_data.message_count,
208 "case_path": rel_path(&self.root, &case_path)
209 });
210 if !message_ids.is_empty() {
211 let locations = self.message_remote_locations_any(&message_ids)?;
212 let item = crate::push_queue::queue_action_steps(
213 &self.root,
214 "case.add",
215 &message_ids,
216 &locations,
217 &config.actions.case_add.steps,
218 None,
219 )?;
220 if let Some(item) = &item {
221 self.record_pending_push_item(item)?;
222 }
223 add_queue_fields(&mut result, locations.len(), item.as_ref());
224 }
225 self.append_audit_event(
226 "case_created",
227 vec![audit_target("case", &case_uid)],
228 reason,
229 json!({
230 "case_uid": case_uid,
231 "case_name": name,
232 "group": group,
233 "message_ids": message_ids,
234 "summary": summary,
235 "case_path": rel_path(&self.root, &case_path),
236 }),
237 )?;
238 Ok(result)
239 }
240
241 pub fn add_message_to_case(
242 &self,
243 case_ref: &str,
244 message_id: &str,
245 summary: Option<&str>,
246 reason: Option<&str>,
247 ) -> Result<Value> {
248 self.require_workspace()?;
249 let reason = self.checked_reason(reason)?;
250 let case_uid = parse_case_ref(case_ref)?;
251 validate_id("message_id", message_id)?;
252 let existing = self.find_case_by_uid(&case_uid)?;
253 if existing.is_none() && self.find_archived_case_by_uid(&case_uid)?.is_some() {
254 return Err(case_archived_error(&case_uid));
255 }
256 let case_path = existing.ok_or_else(|| {
257 AppError::new("case_not_found", format!("case not found: {case_uid}"))
258 })?;
259 let mut result = self.add_message_to_existing_case(
260 &case_uid,
261 message_id,
262 summary.or(reason),
263 &case_path,
264 )?;
265 let config = MailConfig::load(&self.root)?;
266 let message_ids = vec![message_id.to_string()];
267 let locations = self.message_remote_locations_any(&message_ids)?;
268 let item = crate::push_queue::queue_action_steps(
269 &self.root,
270 "case.add",
271 &message_ids,
272 &locations,
273 &config.actions.case_add.steps,
274 None,
275 )?;
276 if let Some(item) = &item {
277 self.record_pending_push_item(item)?;
278 }
279 add_queue_fields(&mut result, locations.len(), item.as_ref());
280 self.append_audit_event(
281 "case_message_added",
282 vec![
283 audit_target("case", &case_uid),
284 audit_target("message", message_id),
285 ],
286 reason,
287 json!({
288 "case_uid": case_uid,
289 "message_id": message_id,
290 "summary": summary,
291 "group": result.get("group").and_then(Value::as_str).unwrap_or_default(),
292 }),
293 )?;
294 Ok(result)
295 }
296
297 pub(super) fn add_message_to_existing_case(
298 &self,
299 case_uid: &str,
300 message_id: &str,
301 summary: Option<&str>,
302 case_path: &Path,
303 ) -> Result<Value> {
304 let related_message_ids = self.related_message_ids(message_id)?;
305 let mut case = read_case_file(case_path)?;
306 let already_present = case.contains_message(message_id);
307 if !already_present {
308 let added_rfc3339 = now_rfc3339();
309 case.upsert_item(message_id, summary, &added_rfc3339);
310 update_case_counts(&mut case, &[message_id.to_string()], None);
311 write_case_file(case_path, &case)?;
312 } else if summary
313 .map(str::trim)
314 .filter(|value| !value.is_empty())
315 .is_some()
316 {
317 let added_rfc3339 = now_rfc3339();
318 case.upsert_item(message_id, summary, &added_rfc3339);
319 case.updated_rfc3339 = Some(added_rfc3339);
320 write_case_file(case_path, &case)?;
321 }
322 self.clear_message_from_all_dispositions(message_id)?;
323 self.refresh_messages_after_ref_change(&[message_id.to_string()])?;
324 self.refresh_case_message_views(case_path)?;
325 let group = case_path
326 .parent()
327 .and_then(Path::file_name)
328 .and_then(|s| s.to_str())
329 .unwrap_or_default();
330 Ok(json!({
331 "code": "case_message_added",
332 "case_uid": case_uid,
333 "message_id": message_id,
334 "group": group,
335 "created_case": false,
336 "message_count": 1,
337 "already_present": already_present,
338 "related_message_ids": related_message_ids,
339 "case_path": rel_path(&self.root, case_path)
340 }))
341 }
342
343 pub fn move_case(&self, case_ref: &str, group: &str) -> Result<Value> {
344 self.require_workspace()?;
345 validate_id("group", group)?;
346 let (case_uid, from) = self.resolve_active_case(case_ref)?;
347 let from_group = from
348 .parent()
349 .and_then(Path::file_name)
350 .and_then(|s| s.to_str())
351 .unwrap_or_default()
352 .to_string();
353 let from_parent = from.parent().map(Path::to_path_buf);
354 let dir_name = from
355 .file_name()
356 .ok_or_else(|| AppError::new("store_error", "case has no directory name"))?;
357 let to = self.root.join("cases").join(group).join(dir_name);
358 if to == from {
359 return Ok(json!({
360 "code": "case_moved",
361 "case_uid": case_uid,
362 "from_group": from_group,
363 "to_group": group,
364 "case_path": rel_path(&self.root, &to)
365 }));
366 }
367 if to.exists() {
368 return Err(AppError::new(
369 "duplicate_case_uid",
370 format!("target case path already exists: {}", path_to_string(&to)),
371 ));
372 }
373 if let Some(parent) = to.parent() {
374 create_dir_all(parent)?;
375 }
376 fs::rename(&from, &to).map_err(|e| AppError::io("move case", &e))?;
377 if let Some(parent) = from_parent {
378 self.remove_empty_case_container_dir(&parent)?;
379 }
380 self.refresh_case_message_views(&to)?;
381 Ok(json!({
382 "code": "case_moved",
383 "case_uid": case_uid,
384 "from_group": from_group,
385 "to_group": group,
386 "case_path": rel_path(&self.root, &to)
387 }))
388 }
389
390 pub(super) fn ensure_case_has_no_local_drafts(
391 &self,
392 case_uid: &str,
393 case_path: &Path,
394 ) -> Result<()> {
395 let drafts_dir = case_path.join("drafts");
396 let mut draft_names = Vec::new();
397 if drafts_dir.exists() {
398 for entry in read_dir(&drafts_dir, "read drafts")? {
399 let path = entry.path();
400 if path.extension().and_then(|s| s.to_str()) == Some("md") {
401 draft_names.push(path_file_name(&path));
402 }
403 }
404 }
405 draft_names.sort();
406 if draft_names.is_empty() {
407 return Ok(());
408 }
409 Err(AppError::new(
410 "case_has_local_drafts",
411 format!(
412 "case {case_uid} has local drafts: {}",
413 draft_names.join(", ")
414 ),
415 )
416 .with_hint(
417 "Queue drafts with `afmail case draft save` or `afmail case draft send`, push them, or remove drafts before archive/merge.",
418 )
419 .with_details(json!({
420 "case_uid": case_uid,
421 "draft_names": draft_names,
422 "suggested_commands": [
423 format!("afmail case draft validate {case_uid} DRAFT_NAME"),
424 format!("afmail case draft save {case_uid} DRAFT_NAME"),
425 format!("afmail case draft send {case_uid} DRAFT_NAME"),
426 format!("afmail case draft remove {case_uid} DRAFT_NAME --reason TEXT")
427 ]
428 })))
429 }
430
431 pub(super) fn ensure_case_has_no_outbound_push(&self, case_uid: &str) -> Result<()> {
432 let push_dir = self.root.join(".afmail/push");
433 if !push_dir.exists() {
434 return Ok(());
435 }
436 let mut push_ids = Vec::new();
437 for entry in read_dir(&push_dir, "read push queue")? {
438 let path = entry.path();
439 if path.extension().and_then(|s| s.to_str()) != Some("json") {
440 continue;
441 }
442 let data = read_to_string(&path, "read push item")?;
443 let item = PushItem::parse_json(&data)?;
444 if item
445 .outbound()
446 .is_some_and(|outbound| outbound.case_uid == case_uid)
447 {
448 push_ids.push(item.push_id);
449 }
450 }
451 push_ids.sort();
452 if push_ids.is_empty() {
453 return Ok(());
454 }
455 Err(AppError::new(
456 "case_has_outbound_push",
457 format!(
458 "case {case_uid} has queued outbound push items: {}",
459 push_ids.join(", ")
460 ),
461 )
462 .with_hint("Push queued drafts or remove the corresponding drafts before archive/merge.")
463 .with_details(json!({
464 "case_uid": case_uid,
465 "push_ids": push_ids,
466 "suggested_commands": [
467 "afmail push --dry-run",
468 "afmail push --confirm",
469 format!("afmail case draft remove {case_uid} DRAFT_NAME --reason TEXT")
470 ]
471 })))
472 }
473
474 pub fn archive_case(&self, case_ref: &str, reason: Option<&str>) -> Result<Value> {
475 self.require_workspace()?;
476 let reason = self.checked_reason(reason)?;
477 let (case_uid, case_path) = self.resolve_active_case(case_ref)?;
478 self.ensure_case_has_no_local_drafts(&case_uid, &case_path)?;
479 self.ensure_case_has_no_outbound_push(&case_uid)?;
480 let from_path = rel_path(&self.root, &case_path);
481 let messages = read_case_messages(&case_path, &case_uid)?;
482 let transaction = self.begin_transaction(
483 "case_archive",
484 vec![
485 from_path.clone(),
486 format!("archive/cases/{case_uid}"),
487 ".afmail/push".to_string(),
488 ],
489 )?;
490 let archived_path = self.archive_active_case_workspace(&case_uid, &case_path)?;
491 let message_ids = messages.message_ids();
492 self.refresh_messages_after_ref_change(&message_ids)?;
493 self.refresh_case_message_views(&archived_path)?;
494 let queue = self.queue_archive_for_archived_messages(&message_ids, None)?;
495 transaction.commit()?;
496 self.append_audit_event(
497 "case_archived",
498 vec![audit_target("case", &case_uid)],
499 reason,
500 json!({
501 "case_uid": case_uid,
502 "from_path": from_path,
503 "to_path": rel_path(&self.root, &archived_path),
504 "message_ids": message_ids,
505 }),
506 )?;
507 Ok(json!({
508 "code": "case_archived",
509 "case_uid": case_uid,
510 "message_count": messages.message_count,
511 "eligible_message_ids": queue.eligible_message_ids,
512 "location_count": queue.location_count,
513 "queued_location_count": queue.queued_location_count,
514 "queued": !queue.items.is_empty(),
515 "push_ids": queue.items.iter().map(|item| item.push_id.clone()).collect::<Vec<_>>(),
516 "push_id": queue.items.first().map(|item| item.push_id.clone()),
517 "from_path": from_path,
518 "case_path": rel_path(&self.root, &archived_path)
519 }))
520 }
521
522 pub fn reopen_case(&self, case_ref: &str, reason: Option<&str>) -> Result<Value> {
523 self.require_workspace()?;
524 let reason = self.checked_reason(reason)?;
525 let (case_uid, case_path) = self.resolve_active_case(case_ref)?;
526 let messages = read_case_messages(&case_path, &case_uid)?;
527 self.set_case_status(&case_path, "active")?;
528 let message_ids = messages.message_ids();
529 self.refresh_messages_after_ref_change(&message_ids)?;
530 let result = json!({
531 "code": "case_reopened",
532 "case_uid": case_uid,
533 "status": "active",
534 "message_count": messages.message_count,
535 "case_path": rel_path(&self.root, &case_path)
536 });
537 self.append_audit_event(
538 "case_reopened",
539 vec![audit_target("case", &case_uid)],
540 reason,
541 json!({"case_uid": case_uid}),
542 )?;
543 Ok(result)
544 }
545
546 pub fn tag_case(&self, case_ref: &str, tag: &str, reason: Option<&str>) -> Result<Value> {
547 self.require_workspace()?;
548 let reason = self.checked_reason(reason)?;
549 validate_id("tag", tag)?;
550 let (case_uid, case_path) = self.resolve_active_case(case_ref)?;
551 let tags = self.update_case_tags(&case_path, Some(tag), None)?;
552 let result = json!({
553 "code": "case_tagged",
554 "case_uid": case_uid,
555 "tag": tag,
556 "tags": tags,
557 "case_path": rel_path(&self.root, &case_path)
558 });
559 self.append_audit_event(
560 "case_tagged",
561 vec![audit_target("case", &case_uid)],
562 reason,
563 json!({"case_uid": case_uid, "tag": tag, "tags": tags}),
564 )?;
565 Ok(result)
566 }
567
568 pub fn untag_case(&self, case_ref: &str, tag: &str, reason: Option<&str>) -> Result<Value> {
569 self.require_workspace()?;
570 let reason = self.checked_reason(reason)?;
571 validate_id("tag", tag)?;
572 let (case_uid, case_path) = self.resolve_active_case(case_ref)?;
573 let tags = self.update_case_tags(&case_path, None, Some(tag))?;
574 let result = json!({
575 "code": "case_untagged",
576 "case_uid": case_uid,
577 "tag": tag,
578 "tags": tags,
579 "case_path": rel_path(&self.root, &case_path)
580 });
581 self.append_audit_event(
582 "case_untagged",
583 vec![audit_target("case", &case_uid)],
584 reason,
585 json!({"case_uid": case_uid, "tag": tag, "tags": tags}),
586 )?;
587 Ok(result)
588 }
589
590 pub fn merge_case(
591 &self,
592 case_ref: &str,
593 other_case_ref: &str,
594 reason: Option<&str>,
595 ) -> Result<Value> {
596 self.require_workspace()?;
597 let reason = self.checked_reason(reason)?;
598 let (case_uid, primary) = self.resolve_active_case(case_ref)?;
599 let (other_case_uid, other) = self.resolve_active_case(other_case_ref)?;
600 if case_uid == other_case_uid {
601 return Err(AppError::new(
602 "invalid_request",
603 "cannot merge a case into itself",
604 ));
605 }
606 self.ensure_case_has_no_local_drafts(&case_uid, &primary)?;
607 self.ensure_case_has_no_local_drafts(&other_case_uid, &other)?;
608 self.ensure_case_has_no_outbound_push(&case_uid)?;
609 self.ensure_case_has_no_outbound_push(&other_case_uid)?;
610 ensure_no_name_conflicts(&primary.join("files"), &other.join("files"), "files")?;
611 ensure_no_name_conflicts(&primary.join("drafts"), &other.join("drafts"), "drafts")?;
612 let mut primary_messages = read_case_messages(&primary, &case_uid)?;
613 let other_messages = read_case_messages(&other, &other_case_uid)?;
614 primary_messages.merge_items(&other_messages.items);
615 primary_messages.updated_rfc3339 = Some(now_rfc3339());
616 write_case_file(&primary, &primary_messages)?;
617 merge_case_notes(
618 &self.root,
619 self.template_language()?,
620 &case_uid,
621 &primary,
622 &other,
623 &other_case_uid,
624 )?;
625 move_children(&other.join("files"), &primary.join("files"))?;
626 move_children(&other.join("drafts"), &primary.join("drafts"))?;
627 let other_parent = other.parent().map(Path::to_path_buf);
628 remove_dir_all(&other)?;
629 if let Some(parent) = other_parent {
630 self.remove_empty_case_container_dir(&parent)?;
631 }
632 let other_message_ids = other_messages.message_ids();
633 self.refresh_messages_after_ref_change(&other_message_ids)?;
634 self.refresh_case_message_views(&primary)?;
635 self.append_audit_event(
636 "case_merged",
637 vec![
638 audit_target("case", &case_uid),
639 audit_target("case", &other_case_uid),
640 ],
641 reason,
642 json!({
643 "case_uid": case_uid,
644 "merged_case_uid": other_case_uid,
645 "message_ids": other_message_ids,
646 }),
647 )?;
648 Ok(json!({
649 "code": "case_merged",
650 "case_uid": case_uid,
651 "merged_case_uid": other_case_uid,
652 "message_count": other_messages.message_count
653 }))
654 }
655
656 pub fn rename_active_case(
657 &self,
658 case_ref: &str,
659 name: &str,
660 reason: Option<&str>,
661 ) -> Result<Value> {
662 self.require_workspace()?;
663 let reason = self.checked_reason(reason)?;
664 validate_name("case_name", name)?;
665 let (case_uid, from) = self.resolve_active_case(case_ref)?;
666 let old_name = case_name(&from)?;
667 let group = from
668 .parent()
669 .and_then(Path::file_name)
670 .and_then(|s| s.to_str())
671 .unwrap_or_default()
672 .to_string();
673 let to = from
674 .parent()
675 .ok_or_else(|| AppError::new("store_error", "case has no parent directory"))?
676 .join(case_dir_name(&case_uid, name));
677 let changed_path = to != from;
678 if changed_path && to.exists() {
679 return Err(AppError::new(
680 "duplicate_case_uid",
681 format!("target case path already exists: {}", path_to_string(&to)),
682 ));
683 }
684 if changed_path {
685 fs::rename(&from, &to).map_err(|e| AppError::io("rename case", &e))?;
686 }
687 update_case_name(&to, name)?;
688 self.refresh_case_message_views(&to)?;
689 self.append_audit_event(
690 "case_renamed",
691 vec![audit_target("case", &case_uid)],
692 reason,
693 json!({
694 "case_uid": case_uid,
695 "old_case_name": old_name,
696 "case_name": name,
697 "group": group,
698 "from_path": rel_path(&self.root, &from),
699 "to_path": rel_path(&self.root, &to),
700 }),
701 )?;
702 Ok(json!({
703 "code": "case_renamed",
704 "case_uid": case_uid,
705 "old_case_name": old_name,
706 "case_name": name,
707 "group": group,
708 "case_path": rel_path(&self.root, &to),
709 "changed": old_name != name || changed_path
710 }))
711 }
712
713 pub fn active_case_show(&self, case_ref: &str) -> Result<Value> {
714 self.require_workspace()?;
715 let (case_uid, case_path) = match self.resolve_active_case(case_ref) {
718 Ok(resolved) => resolved,
719 Err(_) => return self.archive_case_show(case_ref),
720 };
721 self.refresh_case_message_views(&case_path)?;
722 let view_path = case_path.join("case.md");
723 let text = read_to_string(&view_path, "read active case")?;
724 let case = read_case_file(&case_path)?;
725 let group = case_path
726 .parent()
727 .and_then(Path::file_name)
728 .and_then(|s| s.to_str())
729 .unwrap_or_default()
730 .to_string();
731 Ok(json!({
732 "code": "case",
733 "case_uid": case_uid,
734 "case_name": case.collection_name,
735 "group": group,
736 "case_path": rel_path(&self.root, &case_path),
737 "view_path": rel_path(&self.root, &view_path),
738 "messages_path": rel_path(&self.root, &case_views_messages_dir(&case_path)),
739 "text": text,
740 }))
741 }
742
743 pub fn case_list(&self) -> Result<Value> {
744 self.require_workspace()?;
745 let items = self.active_case_items()?;
746 Ok(json!({
747 "code": "case_list",
748 "count": items.len(),
749 "path_templates": {
750 "case_path": "cases/{group}/{case_dir}",
751 "view_path": "cases/{group}/{case_dir}/case.md",
752 "data_path": "cases/{group}/{case_dir}/data/case.json",
753 },
754 "items": items,
755 }))
756 }
757
758 pub fn active_case_notes_show(&self, case_ref: &str) -> Result<Value> {
759 let (case_uid, case_path) = match self.resolve_active_case(case_ref) {
761 Ok(resolved) => resolved,
762 Err(_) => return self.archive_case_notes_show(case_ref),
763 };
764 self.notes_show(
765 "case_notes",
766 vec![audit_target("case", &case_uid)],
767 &case_path.join("notes.md"),
768 )
769 }
770
771 pub fn active_case_notes_append(&self, case_ref: &str, text: &str) -> Result<Value> {
772 let (case_uid, case_path) = self.resolve_active_case(case_ref)?;
773 self.notes_append(
774 "case_notes_appended",
775 vec![audit_target("case", &case_uid)],
776 &case_path.join("notes.md"),
777 text,
778 )
779 }
780
781 pub fn active_case_notes_replace(&self, case_ref: &str, text: &str) -> Result<Value> {
782 let (case_uid, case_path) = self.resolve_active_case(case_ref)?;
783 self.notes_replace(
784 "case_notes_replaced",
785 vec![audit_target("case", &case_uid)],
786 &case_path.join("notes.md"),
787 text,
788 )
789 }
790
791 pub fn archive_case_show(&self, case_ref: &str) -> Result<Value> {
792 self.require_workspace()?;
793 let (case_uid, entry) = self.resolve_archived_case(case_ref)?;
794 self.refresh_case_message_views(&entry.path)?;
795 let path = entry.path.join("case.md");
796 let text = read_to_string(&path, "read archived case")?;
797 let name = case_name(&entry.path)?;
798 Ok(json!({
799 "code": "archive_case",
800 "case_uid": case_uid,
801 "case_name": name,
802 "case_path": rel_path(&self.root, &entry.path),
803 "view_path": rel_path(&self.root, &path),
804 "text": text,
805 }))
806 }
807
808 pub fn archive_case_restore(
809 &self,
810 case_ref: &str,
811 group: &str,
812 reason: Option<&str>,
813 ) -> Result<Value> {
814 self.require_workspace()?;
815 let reason = self.checked_reason(reason)?;
816 validate_id("group", group)?;
817 let (case_uid, entry) = self.resolve_archived_case(case_ref)?;
818 let dir_name = entry
819 .path
820 .file_name()
821 .ok_or_else(|| AppError::new("store_error", "case has no directory name"))?;
822 let active_path = self.root.join("cases").join(group).join(dir_name);
823 if active_path.exists() {
824 return Err(AppError::new(
825 "case_exists",
826 format!(
827 "active case path already exists: {}",
828 path_to_string(&active_path)
829 ),
830 ));
831 }
832 let messages = read_case_messages(&entry.path, &case_uid)?;
833 update_case_archive_state(&entry.path, "active")?;
834 if let Some(parent) = active_path.parent() {
835 create_dir_all(parent)?;
836 }
837 fs::rename(&entry.path, &active_path).map_err(|e| AppError::io("restore case", &e))?;
838 let message_ids = messages.message_ids();
839 self.refresh_messages_after_ref_change(&message_ids)?;
840 self.refresh_case_message_views(&active_path)?;
841 self.append_audit_event(
842 "case_restored",
843 vec![audit_target("case", &case_uid)],
844 reason,
845 json!({
846 "case_uid": case_uid,
847 "to_group": group,
848 "from_path": rel_path(&self.root, &entry.path),
849 "to_path": rel_path(&self.root, &active_path),
850 }),
851 )?;
852 Ok(json!({
853 "code": "case_restored",
854 "case_uid": case_uid,
855 "group": group,
856 "case_path": rel_path(&self.root, &active_path)
857 }))
858 }
859
860 pub fn archive_case_rename(
861 &self,
862 case_ref: &str,
863 name: &str,
864 reason: Option<&str>,
865 ) -> Result<Value> {
866 self.require_workspace()?;
867 let reason = self.checked_reason(reason)?;
868 validate_name("case_name", name)?;
869 let (case_uid, entry) = self.resolve_archived_case(case_ref)?;
870 let old_name = case_name(&entry.path)?;
871 let to = self.archive_case_path_for_name(&case_uid, name);
872 let changed_path = to != entry.path;
873 if changed_path && to.exists() {
874 return Err(AppError::new(
875 "duplicate_case_uid",
876 format!(
877 "target archived case path already exists: {}",
878 path_to_string(&to)
879 ),
880 ));
881 }
882 if let Some(parent) = to.parent() {
883 create_dir_all(parent)?;
884 }
885 if changed_path {
886 fs::rename(&entry.path, &to).map_err(|e| AppError::io("rename archived case", &e))?;
887 }
888 update_case_name(&to, name)?;
889 self.refresh_case_message_views(&to)?;
890 self.append_audit_event(
891 "archive_case_renamed",
892 vec![audit_target("case", &case_uid)],
893 reason,
894 json!({
895 "case_uid": case_uid,
896 "old_case_name": old_name,
897 "case_name": name,
898 "from_path": rel_path(&self.root, &entry.path),
899 "to_path": rel_path(&self.root, &to),
900 }),
901 )?;
902 Ok(json!({
903 "code": "archive_case_renamed",
904 "case_uid": case_uid,
905 "old_case_name": old_name,
906 "case_name": name,
907 "case_path": rel_path(&self.root, &to),
908 "changed": old_name != name || changed_path
909 }))
910 }
911
912 pub fn archive_case_notes_show(&self, case_ref: &str) -> Result<Value> {
913 let (case_uid, entry) = self.resolve_archived_case(case_ref)?;
914 self.notes_show(
915 "case_notes",
916 vec![audit_target("case", &case_uid)],
917 &entry.path.join("notes.md"),
918 )
919 }
920
921 pub fn archive_case_notes_append(&self, case_ref: &str, text: &str) -> Result<Value> {
922 let (case_uid, entry) = self.resolve_archived_case(case_ref)?;
923 self.notes_append(
924 "case_notes_appended",
925 vec![audit_target("case", &case_uid)],
926 &entry.path.join("notes.md"),
927 text,
928 )
929 }
930
931 pub fn archive_case_notes_replace(&self, case_ref: &str, text: &str) -> Result<Value> {
932 let (case_uid, entry) = self.resolve_archived_case(case_ref)?;
933 self.notes_replace(
934 "case_notes_replaced",
935 vec![audit_target("case", &case_uid)],
936 &entry.path.join("notes.md"),
937 text,
938 )
939 }
940
941 pub(super) fn archive_active_case_workspace(
942 &self,
943 case_uid: &str,
944 case_path: &Path,
945 ) -> Result<PathBuf> {
946 validate_case_uid(case_uid)?;
947 let dir_name = case_path
948 .file_name()
949 .ok_or_else(|| AppError::new("store_error", "case has no directory name"))?;
950 let archived_path = self.root.join("archive").join("cases").join(dir_name);
951 if archived_path.exists() {
952 return Err(AppError::new(
953 "case_exists",
954 format!(
955 "archived case already exists: {}",
956 path_to_string(&archived_path)
957 ),
958 ));
959 }
960 if let Some(parent) = archived_path.parent() {
961 create_dir_all(parent)?;
962 }
963 let source_parent = case_path.parent().map(Path::to_path_buf);
964 fs::rename(case_path, &archived_path).map_err(|e| AppError::io("archive case", &e))?;
965 update_case_archive_state(&archived_path, "archived")?;
966 if let Some(parent) = source_parent {
967 self.remove_empty_case_container_dir(&parent)?;
968 }
969 Ok(archived_path)
970 }
971
972 pub(super) fn remove_empty_case_container_dir(&self, dir: &Path) -> Result<bool> {
973 if !self.is_removable_case_container_dir(dir) {
974 return Ok(false);
975 }
976 match fs::remove_dir(dir) {
977 Ok(()) => Ok(true),
978 Err(e)
979 if matches!(
980 e.kind(),
981 std::io::ErrorKind::NotFound | std::io::ErrorKind::DirectoryNotEmpty
982 ) =>
983 {
984 Ok(false)
985 }
986 Err(e) => Err(AppError::io("remove empty case container directory", &e)),
987 }
988 }
989
990 pub(super) fn is_removable_case_container_dir(&self, dir: &Path) -> bool {
991 let active_cases_dir = self.root.join("cases");
992 dir.parent() == Some(active_cases_dir.as_path()) && dir != active_cases_dir
993 }
994
995 pub(super) fn active_case_items(&self) -> Result<Vec<Value>> {
996 let mut out = Vec::new();
997 for (case_uid, path) in self.case_entries()? {
998 let group = path
999 .parent()
1000 .and_then(Path::file_name)
1001 .and_then(|s| s.to_str())
1002 .unwrap_or_default()
1003 .to_string();
1004 out.push(json!({
1005 "case_uid": case_uid,
1006 "case_name": case_name(&path).unwrap_or_default(),
1007 "group": group,
1008 "case_dir": path
1009 .file_name()
1010 .and_then(|s| s.to_str())
1011 .unwrap_or_default(),
1012 }));
1013 }
1014 out.sort_by(|a, b| {
1015 let a_key = (
1016 a.get("group").and_then(Value::as_str).unwrap_or_default(),
1017 a.get("case_uid")
1018 .and_then(Value::as_str)
1019 .unwrap_or_default(),
1020 );
1021 let b_key = (
1022 b.get("group").and_then(Value::as_str).unwrap_or_default(),
1023 b.get("case_uid")
1024 .and_then(Value::as_str)
1025 .unwrap_or_default(),
1026 );
1027 a_key.cmp(&b_key)
1028 });
1029 Ok(out)
1030 }
1031
1032 pub(super) fn archive_case_items(&self) -> Result<Vec<Value>> {
1033 Ok(self
1034 .archived_case_entries()?
1035 .into_iter()
1036 .map(|entry| {
1037 json!({
1038 "case_uid": entry.case_uid,
1039 "case_name": case_name(&entry.path).unwrap_or_default(),
1040 "case_dir": entry
1041 .path
1042 .file_name()
1043 .and_then(|s| s.to_str())
1044 .unwrap_or_default(),
1045 })
1046 })
1047 .collect())
1048 }
1049
1050 pub(super) fn notes_show(&self, code: &str, targets: Vec<Value>, path: &Path) -> Result<Value> {
1051 let text = read_existing_notes(&self.root, path)?;
1052 Ok(json!({
1053 "code": code,
1054 "targets": targets,
1055 "notes_path": rel_path(&self.root, path),
1056 "text": text
1057 }))
1058 }
1059
1060 pub(super) fn notes_append(
1061 &self,
1062 kind: &str,
1063 targets: Vec<Value>,
1064 path: &Path,
1065 text: &str,
1066 ) -> Result<Value> {
1067 let mut existing = read_existing_notes(&self.root, path)?;
1068 if !existing.ends_with('\n') {
1069 existing.push('\n');
1070 }
1071 existing.push_str(text);
1072 if !existing.ends_with('\n') {
1073 existing.push('\n');
1074 }
1075 write_string(path, &existing)?;
1076 self.append_audit_event(
1077 kind,
1078 targets.clone(),
1079 None,
1080 json!({
1081 "operation": "append",
1082 "notes_path": rel_path(&self.root, path),
1083 "text_len_bytes": text.len(),
1084 "text_hash": stable_text_hash(text),
1085 }),
1086 )?;
1087 Ok(json!({
1088 "code": kind,
1089 "targets": targets,
1090 "notes_path": rel_path(&self.root, path),
1091 "text_len_bytes": text.len(),
1092 "text_hash": stable_text_hash(text)
1093 }))
1094 }
1095
1096 pub(super) fn notes_replace(
1097 &self,
1098 kind: &str,
1099 targets: Vec<Value>,
1100 path: &Path,
1101 text: &str,
1102 ) -> Result<Value> {
1103 let mut data = text.to_string();
1104 if !data.ends_with('\n') {
1105 data.push('\n');
1106 }
1107 write_string(path, &data)?;
1108 self.append_audit_event(
1109 kind,
1110 targets.clone(),
1111 None,
1112 json!({
1113 "operation": "replace",
1114 "notes_path": rel_path(&self.root, path),
1115 "text_len_bytes": text.len(),
1116 "text_hash": stable_text_hash(text),
1117 }),
1118 )?;
1119 Ok(json!({
1120 "code": kind,
1121 "targets": targets,
1122 "notes_path": rel_path(&self.root, path),
1123 "text_len_bytes": text.len(),
1124 "text_hash": stable_text_hash(text)
1125 }))
1126 }
1127
1128 pub fn find_case_required(&self, case_ref: &str) -> Result<PathBuf> {
1129 self.resolve_active_case(case_ref).map(|(_, path)| path)
1130 }
1131
1132 pub(super) fn resolve_active_case(&self, case_ref: &str) -> Result<(String, PathBuf)> {
1133 let case_uid = parse_case_ref(case_ref)?;
1134 if let Some(path) = self.find_case_by_uid(&case_uid)? {
1135 return Ok((case_uid, path));
1136 }
1137 if self.find_archived_case_by_uid(&case_uid)?.is_some() {
1138 return Err(case_archived_error(&case_uid));
1139 }
1140 Err(AppError::new(
1141 "case_not_found",
1142 format!("case not found: {case_uid}"),
1143 ))
1144 }
1145
1146 pub(super) fn resolve_archived_case(
1147 &self,
1148 case_ref: &str,
1149 ) -> Result<(String, ArchivedCaseEntry)> {
1150 let case_uid = parse_case_ref(case_ref)?;
1151 self.find_archived_case_by_uid(&case_uid)?
1152 .map(|entry| (case_uid.clone(), entry))
1153 .ok_or_else(|| {
1154 AppError::new(
1155 "case_not_found",
1156 format!("archived case not found: {case_uid}"),
1157 )
1158 })
1159 }
1160
1161 pub fn find_case(&self, case_ref: &str) -> Result<Option<PathBuf>> {
1162 let case_uid = parse_case_ref(case_ref)?;
1163 self.find_case_by_uid(&case_uid)
1164 }
1165
1166 pub(super) fn find_case_by_uid(&self, case_uid: &str) -> Result<Option<PathBuf>> {
1167 validate_case_uid(case_uid)?;
1168 let mut matches = Vec::new();
1169 for (id, path) in self.case_entries()? {
1170 if id == case_uid {
1171 matches.push(path);
1172 }
1173 }
1174 match matches.len() {
1175 0 => Ok(None),
1176 1 => Ok(matches.into_iter().next()),
1177 _ => Err(AppError::new(
1178 "duplicate_case_uid",
1179 format!("duplicate case uid found: {case_uid}"),
1180 )),
1181 }
1182 }
1183
1184 pub(super) fn find_archived_case_by_uid(
1185 &self,
1186 case_uid: &str,
1187 ) -> Result<Option<ArchivedCaseEntry>> {
1188 validate_case_uid(case_uid)?;
1189 let mut matches = Vec::new();
1190 for entry in self.archived_case_entries()? {
1191 if entry.case_uid == case_uid {
1192 matches.push(entry);
1193 }
1194 }
1195 match matches.len() {
1196 0 => Ok(None),
1197 1 => Ok(matches.into_iter().next()),
1198 _ => Err(AppError::new(
1199 "duplicate_case_uid",
1200 format!("duplicate archived case uid found: {case_uid}"),
1201 )),
1202 }
1203 }
1204
1205 pub(super) fn case_entries(&self) -> Result<Vec<(String, PathBuf)>> {
1206 let cases_dir = self.root.join("cases");
1207 if !cases_dir.exists() {
1208 return Ok(Vec::new());
1209 }
1210 let mut out = Vec::new();
1211 for group_entry in read_dir(&cases_dir, "read cases directory")? {
1212 let group_path = group_entry.path();
1213 if !group_path.is_dir() {
1214 continue;
1215 }
1216 for case_entry in read_dir(&group_path, "read case group")? {
1217 let case_path = case_entry.path();
1218 if !case_path.is_dir() || !case_json_path(&case_path).is_file() {
1219 continue;
1220 }
1221 let fm = read_case_file(&case_path)?;
1222 out.push((fm.collection_uid, case_path));
1223 }
1224 }
1225 Ok(out)
1226 }
1227
1228 pub(super) fn all_case_entries(&self) -> Result<Vec<(String, PathBuf)>> {
1229 let mut out = self.case_entries()?;
1230 out.extend(
1231 self.archived_case_entries()?
1232 .into_iter()
1233 .map(|entry| (entry.case_uid, entry.path)),
1234 );
1235 Ok(out)
1236 }
1237
1238 pub(super) fn archived_case_entries(&self) -> Result<Vec<ArchivedCaseEntry>> {
1239 let cases_dir = self.root.join("archive/cases");
1240 if !cases_dir.exists() {
1241 return Ok(Vec::new());
1242 }
1243 let mut out = Vec::new();
1244 for case_entry in read_dir(&cases_dir, "read archived cases")? {
1245 let case_path = case_entry.path();
1246 if !case_path.is_dir() || !case_json_path(&case_path).is_file() {
1247 continue;
1248 }
1249 let fm = read_case_file(&case_path)?;
1250 out.push(ArchivedCaseEntry {
1251 case_uid: fm.collection_uid,
1252 path: case_path,
1253 });
1254 }
1255 out.sort_by(|a, b| a.case_uid.cmp(&b.case_uid));
1256 Ok(out)
1257 }
1258
1259 pub(super) fn set_case_status(&self, case_path: &Path, status: &str) -> Result<()> {
1260 let mut fm = read_case_file(case_path)?;
1261 fm.status = status.to_string();
1262 fm.updated_rfc3339 = Some(now_rfc3339());
1263 if status != "archived" {
1264 fm.archived_rfc3339 = None;
1265 }
1266 write_case_file(case_path, &fm)
1267 }
1268
1269 pub(super) fn update_case_tags(
1270 &self,
1271 case_path: &Path,
1272 add_tag: Option<&str>,
1273 remove_tag: Option<&str>,
1274 ) -> Result<Vec<String>> {
1275 let mut fm = read_case_file(case_path)?;
1276 if let Some(tag) = add_tag {
1277 merge_string(&mut fm.tags, tag);
1278 }
1279 if let Some(tag) = remove_tag {
1280 fm.tags.retain(|item| item != tag);
1281 }
1282 fm.updated_rfc3339 = Some(now_rfc3339());
1283 let tags = fm.tags.clone();
1284 write_case_file(case_path, &fm)?;
1285 Ok(tags)
1286 }
1287}