1use crate::error::Result as TResult;
7use crate::error::ThoughtsError;
8use crate::utils::validation::validate_simple_filename;
9use crate::workspace::ActiveWork;
10use crate::workspace::ensure_active_work;
11use atomicwrites::AtomicFile;
12use atomicwrites::OverwriteBehavior;
13use chrono::DateTime;
14use chrono::Utc;
15use schemars::JsonSchema;
16use serde::Deserialize;
17use serde::Serialize;
18use std::fs;
19use std::path::PathBuf;
20
21#[derive(Debug, Clone, Serialize, JsonSchema)]
23#[serde(rename_all = "snake_case")]
24pub enum DocumentType {
25 Research,
26 Plan,
27 Artifact,
28 Log,
29}
30
31impl DocumentType {
32 pub fn subdir<'a>(&self, aw: &'a ActiveWork) -> &'a PathBuf {
34 match self {
35 DocumentType::Research => &aw.research,
36 DocumentType::Plan => &aw.plans,
37 DocumentType::Artifact => &aw.artifacts,
38 DocumentType::Log => &aw.logs,
39 }
40 }
41
42 pub fn subdir_name(&self) -> &'static str {
47 match self {
48 DocumentType::Research => "research",
49 DocumentType::Plan => "plans",
50 DocumentType::Artifact => "artifacts",
51 DocumentType::Log => "logs",
52 }
53 }
54
55 pub fn singular_label(&self) -> &'static str {
57 match self {
58 DocumentType::Research => "research",
59 DocumentType::Plan => "plan",
60 DocumentType::Artifact => "artifact",
61 DocumentType::Log => "log",
62 }
63 }
64}
65
66impl<'de> serde::Deserialize<'de> for DocumentType {
68 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
69 where
70 D: serde::Deserializer<'de>,
71 {
72 let s = String::deserialize(deserializer)?;
73 let norm = s.trim().to_ascii_lowercase();
74 match norm.as_str() {
75 "research" => Ok(DocumentType::Research),
76 "plan" | "plans" => Ok(DocumentType::Plan),
77 "artifact" | "artifacts" => Ok(DocumentType::Artifact),
78 "log" | "logs" => Ok(DocumentType::Log), other => Err(serde::de::Error::custom(format!(
80 "invalid doc_type '{}'; expected research|plan(s)|artifact(s)|log(s)",
81 other
82 ))),
83 }
84 }
85}
86
87#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
89pub struct WriteDocumentOk {
90 pub path: String,
91 pub bytes_written: u64,
92}
93
94#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
96pub struct DocumentInfo {
97 pub path: String,
98 pub doc_type: String,
99 pub size: u64,
100 pub modified: String,
101}
102
103#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
105pub struct ActiveDocuments {
106 pub base: String,
107 pub files: Vec<DocumentInfo>,
108}
109
110pub fn write_document(
120 doc_type: DocumentType,
121 filename: &str,
122 content: &str,
123) -> TResult<WriteDocumentOk> {
124 validate_simple_filename(filename)?;
125 let aw = ensure_active_work()?;
126 let dir = doc_type.subdir(&aw);
127 let target = dir.join(filename);
128 let bytes_written = content.len() as u64;
129
130 AtomicFile::new(&target, OverwriteBehavior::AllowOverwrite)
131 .write(|f| std::io::Write::write_all(f, content.as_bytes()))
132 .map_err(|e| ThoughtsError::Io(std::io::Error::other(e)))?;
133
134 Ok(WriteDocumentOk {
135 path: format!(
136 "./thoughts/{}/{}/{}",
137 aw.dir_name,
138 doc_type.subdir_name(),
139 filename
140 ),
141 bytes_written,
142 })
143}
144
145pub fn list_documents(subdir: Option<DocumentType>) -> TResult<ActiveDocuments> {
154 let aw = ensure_active_work()?;
155 let base = format!("./thoughts/{}", aw.dir_name);
156
157 let sets: Vec<(&str, &str, PathBuf)> = match subdir {
160 Some(ref d) => {
161 vec![(d.singular_label(), d.subdir_name(), d.subdir(&aw).clone())]
162 }
163 None => vec![
164 ("research", "research", aw.research.clone()),
165 ("plan", "plans", aw.plans.clone()),
166 ("artifact", "artifacts", aw.artifacts.clone()),
167 ],
169 };
170
171 let mut files = Vec::new();
172 for (singular_label, dirname, dir) in sets {
173 if !dir.exists() {
174 continue;
175 }
176 for entry in fs::read_dir(&dir)? {
177 let entry = entry?;
178 let meta = entry.metadata()?;
179 if meta.is_file() {
180 let modified: DateTime<Utc> = meta
181 .modified()
182 .map(|t| t.into())
183 .unwrap_or_else(|_| Utc::now());
184 let file_name = entry.file_name().to_string_lossy().to_string();
185 files.push(DocumentInfo {
186 path: format!("{}/{}/{}", base, dirname, file_name),
187 doc_type: singular_label.to_string(),
188 size: meta.len(),
189 modified: modified.to_rfc3339(),
190 });
191 }
192 }
193 }
194
195 Ok(ActiveDocuments { base, files })
196}
197
198pub fn active_logs_dir() -> TResult<PathBuf> {
206 let aw = ensure_active_work()?;
207 if !aw.logs.exists() {
208 std::fs::create_dir_all(&aw.logs)?;
209 }
210 Ok(aw.logs.clone())
211}
212
213#[cfg(test)]
214mod tests {
215 use super::*;
216
217 #[test]
218 fn test_document_type_deserialize_singular() {
219 let research: DocumentType = serde_json::from_str("\"research\"").unwrap();
220 assert!(matches!(research, DocumentType::Research));
221
222 let plan: DocumentType = serde_json::from_str("\"plan\"").unwrap();
223 assert!(matches!(plan, DocumentType::Plan));
224
225 let artifact: DocumentType = serde_json::from_str("\"artifact\"").unwrap();
226 assert!(matches!(artifact, DocumentType::Artifact));
227
228 let log: DocumentType = serde_json::from_str("\"log\"").unwrap();
229 assert!(matches!(log, DocumentType::Log));
230 }
231
232 #[test]
233 fn test_document_type_deserialize_plural() {
234 let plans: DocumentType = serde_json::from_str("\"plans\"").unwrap();
235 assert!(matches!(plans, DocumentType::Plan));
236
237 let artifacts: DocumentType = serde_json::from_str("\"artifacts\"").unwrap();
238 assert!(matches!(artifacts, DocumentType::Artifact));
239
240 let logs: DocumentType = serde_json::from_str("\"logs\"").unwrap();
241 assert!(matches!(logs, DocumentType::Log));
242 }
243
244 #[test]
245 fn test_document_type_deserialize_case_insensitive() {
246 let plan: DocumentType = serde_json::from_str("\"PLAN\"").unwrap();
247 assert!(matches!(plan, DocumentType::Plan));
248
249 let research: DocumentType = serde_json::from_str("\"Research\"").unwrap();
250 assert!(matches!(research, DocumentType::Research));
251
252 let log: DocumentType = serde_json::from_str("\"LOG\"").unwrap();
253 assert!(matches!(log, DocumentType::Log));
254
255 let logs: DocumentType = serde_json::from_str("\"LOGS\"").unwrap();
256 assert!(matches!(logs, DocumentType::Log));
257 }
258
259 #[test]
260 fn test_document_type_deserialize_invalid() {
261 let result: Result<DocumentType, _> = serde_json::from_str("\"invalid\"");
262 assert!(result.is_err());
263 let err = result.unwrap_err().to_string();
264 assert!(err.contains("invalid doc_type"));
265 }
266
267 #[test]
268 fn test_document_type_serialize() {
269 let plan = DocumentType::Plan;
270 let serialized = serde_json::to_string(&plan).unwrap();
271 assert_eq!(serialized, "\"plan\"");
272
273 let artifact = DocumentType::Artifact;
274 let serialized = serde_json::to_string(&artifact).unwrap();
275 assert_eq!(serialized, "\"artifact\"");
276
277 let log = DocumentType::Log;
278 let serialized = serde_json::to_string(&log).unwrap();
279 assert_eq!(serialized, "\"log\"");
280 }
281
282 #[test]
283 fn test_subdir_names() {
284 assert_eq!(DocumentType::Research.subdir_name(), "research");
285 assert_eq!(DocumentType::Plan.subdir_name(), "plans");
286 assert_eq!(DocumentType::Artifact.subdir_name(), "artifacts");
287 assert_eq!(DocumentType::Log.subdir_name(), "logs");
288 }
289
290 #[test]
291 fn test_singular_labels() {
292 assert_eq!(DocumentType::Research.singular_label(), "research");
293 assert_eq!(DocumentType::Plan.singular_label(), "plan");
294 assert_eq!(DocumentType::Artifact.singular_label(), "artifact");
295 assert_eq!(DocumentType::Log.singular_label(), "log");
296 }
297}