Skip to main content

agent_first_mail/store/
cases.rs

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}