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