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