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