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, 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/// Document type categories for thoughts workspace.
17#[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    /// Returns the path for this document type's directory within ActiveWork.
28    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    /// Returns the plural directory name (for physical directory paths).
38    /// Note: serde serialization uses singular forms ("plan", "artifact", "research", "log"),
39    /// while physical directories use plural forms ("plans", "artifacts", "research", "logs").
40    /// This matches conventional filesystem naming while keeping API values consistent.
41    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    /// Returns the singular label for this document type (used in output/reporting).
51    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
61// Custom deserializer: accept singular/plural in a case-insensitive manner
62impl<'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), // accepts both for backward compat
74            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/// Result of successfully writing a document.
83#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
84pub struct WriteDocumentOk {
85    pub path: String,
86    pub bytes_written: u64,
87}
88
89/// Metadata about a single document file.
90#[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/// Result of listing documents in the active work directory.
99#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
100pub struct ActiveDocuments {
101    pub base: String,
102    pub files: Vec<DocumentInfo>,
103}
104
105/// Write a document to the active work directory.
106///
107/// # Arguments
108/// * `doc_type` - The type of document (research, plan, artifact, log)
109/// * `filename` - The filename (validated for safety)
110/// * `content` - The content to write
111///
112/// # Returns
113/// A `WriteDocumentOk` with the path and bytes written on success.
114pub 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
140/// List documents in the active work directory.
141///
142/// # Arguments
143/// * `subdir` - Optional filter for a specific document type. If None, lists research, plans, artifacts
144///   (but NOT logs by default - logs must be explicitly requested).
145///
146/// # Returns
147/// An `ActiveDocuments` with the base path and list of files.
148pub fn list_documents(subdir: Option<DocumentType>) -> TResult<ActiveDocuments> {
149    let aw = ensure_active_work()?;
150    let base = format!("./thoughts/{}", aw.dir_name);
151
152    // Determine which subdirs to scan
153    // Tuple: (singular_label for doc_type output, plural_dirname for paths, PathBuf)
154    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            // Do NOT include logs by default - must be explicitly requested
163        ],
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
193/// Get the path to the logs directory in the active work, ensuring it exists.
194///
195/// This is a convenience function for other crates that need to write log files
196/// directly (e.g., agentic_logging).
197///
198/// # Returns
199/// The absolute path to the logs directory.
200pub 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}