Skip to main content

thoughts_tool/documents/
mod.rs

1//! Library-level document management for thoughts_tool.
2//!
3//! This module provides reusable functions for writing and listing documents,
4//! and is used by both the MCP layer and other crates that depend on thoughts_tool.
5
6use 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/// Document type categories for thoughts workspace.
22#[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    /// Returns the path for this document type's directory within ActiveWork.
33    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    /// Returns the plural directory name (for physical directory paths).
43    /// Note: serde serialization uses singular forms ("plan", "artifact", "research", "log"),
44    /// while physical directories use plural forms ("plans", "artifacts", "research", "logs").
45    /// This matches conventional filesystem naming while keeping API values consistent.
46    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    /// Returns the singular label for this document type (used in output/reporting).
56    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
66// Custom deserializer: accept singular/plural in a case-insensitive manner
67impl<'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), // accepts both for backward compat
79            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/// Result of successfully writing a document.
88#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
89pub struct WriteDocumentOk {
90    pub path: String,
91    pub bytes_written: u64,
92}
93
94/// Metadata about a single document file.
95#[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/// Result of listing documents in the active work directory.
104#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
105pub struct ActiveDocuments {
106    pub base: String,
107    pub files: Vec<DocumentInfo>,
108}
109
110/// Write a document to the active work directory.
111///
112/// # Arguments
113/// * `doc_type` - The type of document (research, plan, artifact, log)
114/// * `filename` - The filename (validated for safety)
115/// * `content` - The content to write
116///
117/// # Returns
118/// A `WriteDocumentOk` with the path and bytes written on success.
119pub 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
145/// List documents in the active work directory.
146///
147/// # Arguments
148/// * `subdir` - Optional filter for a specific document type. If None, lists research, plans, artifacts
149///   (but NOT logs by default - logs must be explicitly requested).
150///
151/// # Returns
152/// An `ActiveDocuments` with the base path and list of files.
153pub fn list_documents(subdir: Option<DocumentType>) -> TResult<ActiveDocuments> {
154    let aw = ensure_active_work()?;
155    let base = format!("./thoughts/{}", aw.dir_name);
156
157    // Determine which subdirs to scan
158    // Tuple: (singular_label for doc_type output, plural_dirname for paths, PathBuf)
159    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            // Do NOT include logs by default - must be explicitly requested
168        ],
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
198/// Get the path to the logs directory in the active work, ensuring it exists.
199///
200/// This is a convenience function for other crates that need to write log files
201/// directly (e.g., agentic_logging).
202///
203/// # Returns
204/// The absolute path to the logs directory.
205pub 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}