forge_agent/userdata/catalog/
mutations.rs1use std::fs;
6use std::io::Write;
7use std::path::{Path, PathBuf};
8
9use serde_json::json;
10
11use forge_sdk::{Error, projects_dir_for};
12
13use crate::userdata::catalog::scan::is_valid_uuid;
14
15pub fn tag_session(
32 config_dir: &Path,
33 session_id: &str,
34 tag: Option<&str>,
35 project: &str,
36 directory: Option<&str>,
37) -> Result<(), Error> {
38 validate_uuid(session_id)?;
39 let stored: String = match tag {
40 None => String::new(),
41 Some(raw) => {
42 let stripped = raw.trim();
43 if stripped.is_empty() {
44 return Err(Error::message_parse("tag must be non-empty (use None to clear)"));
45 }
46 stripped.to_string()
47 }
48 };
49 if project.trim().is_empty() {
50 return Err(Error::message_parse(
51 "project must be non-empty: an unscoped tag row cannot be told from another \
52 project's",
53 ));
54 }
55 let payload = json!({
56 "type": "tag",
57 "tag": stored,
58 "project": project.trim(),
63 "sessionId": session_id,
64 });
65 append_to_session(config_dir, session_id, directory, &payload)
66}
67
68fn validate_uuid(s: &str) -> Result<(), Error> {
71 if is_valid_uuid(s) {
72 Ok(())
73 } else {
74 Err(Error::message_parse(format!("Invalid session_id: {s}")))
75 }
76}
77
78fn find_session_file(
79 config_dir: &Path,
80 session_id: &str,
81 directory: Option<&str>,
82) -> Option<PathBuf> {
83 let file_name = format!("{session_id}.jsonl");
84 if let Some(dir) = directory {
85 let canonical = match fs::canonicalize(dir) {
86 Ok(p) => p.to_string_lossy().into_owned(),
87 Err(_) => dir.to_string(),
88 };
89 let project_dir = projects_dir_for(config_dir)
90 .join(crate::userdata::catalog::scan::sanitize_path_public(&canonical));
91 let candidate = project_dir.join(&file_name);
92 return candidate.is_file().then_some(candidate);
93 }
94 fs::read_dir(projects_dir_for(config_dir))
95 .ok()
96 .and_then(|iter| iter.flatten().map(|e| e.path().join(&file_name)).find(|p| p.is_file()))
97}
98
99fn append_to_session(
100 config_dir: &Path,
101 session_id: &str,
102 directory: Option<&str>,
103 payload: &serde_json::Value,
104) -> Result<(), Error> {
105 let path = find_session_file(config_dir, session_id, directory).ok_or_else(|| {
106 Error::Io(std::io::Error::new(
107 std::io::ErrorKind::NotFound,
108 format!("session {session_id} not found"),
109 ))
110 })?;
111 let mut line =
112 serde_json::to_string(payload).map_err(|e| Error::encode("mutation payload", e))?;
113 line.push('\n');
114 append_line(&path, line.as_bytes())
115}
116
117fn append_line(path: &Path, data: &[u8]) -> Result<(), Error> {
118 let mut file = fs::OpenOptions::new().append(true).open(path)?;
119 file.write_all(data)?;
120 Ok(())
121}
122
123#[cfg(test)]
124mod tests {
125
126 use super::*;
127
128 fn fake_config_dir() -> PathBuf {
129 PathBuf::from("/tmp/forge_mutations_test_cfg")
130 }
131
132 #[test]
133 fn empty_tag_is_rejected() {
134 let session_id = "550e8400-e29b-41d4-a716-446655440001";
135 let r = tag_session(&fake_config_dir(), session_id, Some(" "), "Default/forge", None);
136 assert!(matches!(r, Err(Error::MessageParse { .. })));
137 }
138}