metis_docs_cli/commands/
list.rs

1use crate::workspace;
2use anyhow::Result;
3use clap::Args;
4use metis_core::{Database, Result as MetisResult};
5
6#[derive(Args)]
7pub struct ListCommand {
8    /// Document type to filter by (vision, strategy, initiative, task, adr)
9    #[arg(short = 't', long)]
10    pub document_type: Option<String>,
11
12    /// Phase to filter by (draft, active, completed, etc.)
13    #[arg(short = 'p', long)]
14    pub phase: Option<String>,
15
16    /// Show all documents regardless of type
17    #[arg(short = 'a', long)]
18    pub all: bool,
19
20    /// Include archived documents in the list
21    #[arg(long)]
22    pub include_archived: bool,
23}
24
25impl ListCommand {
26    pub async fn execute(&self) -> Result<()> {
27        // 1. Validate we're in a metis workspace
28        let (workspace_exists, metis_dir) = workspace::has_metis_vault();
29        if !workspace_exists {
30            anyhow::bail!("Not in a Metis workspace. Run 'metis init' to create one.");
31        }
32        let metis_dir = metis_dir.unwrap();
33
34        // 2. Connect to database
35        let db_path = metis_dir.join("metis.db");
36        let db = Database::new(db_path.to_str().unwrap())
37            .map_err(|e| anyhow::anyhow!("Database connection failed: {}", e))?;
38        let mut repo = db.into_repository();
39
40        // 3. Query documents based on filters
41        let documents = if self.all {
42            // Show all documents
43            self.list_all_documents(&mut repo).await?
44        } else if let Some(doc_type) = &self.document_type {
45            if let Some(phase) = &self.phase {
46                // Filter by both type and phase
47                repo.find_by_type_and_phase(doc_type, phase)
48                    .map_err(|e| anyhow::anyhow!("Database query failed: {}", e))?
49            } else {
50                // Filter by type only
51                repo.find_by_type(doc_type)
52                    .map_err(|e| anyhow::anyhow!("Database query failed: {}", e))?
53            }
54        } else if let Some(phase) = &self.phase {
55            // Filter by phase only
56            repo.find_by_phase(phase)
57                .map_err(|e| anyhow::anyhow!("Database query failed: {}", e))?
58        } else {
59            // Default: show all documents
60            self.list_all_documents(&mut repo).await?
61        };
62
63        // 4. Display results
64        if documents.is_empty() {
65            println!("No documents found matching the criteria.");
66            return Ok(());
67        }
68
69        self.display_documents(&documents);
70
71        Ok(())
72    }
73
74    async fn list_all_documents(
75        &self,
76        repo: &mut metis_core::dal::database::repository::DocumentRepository,
77    ) -> MetisResult<Vec<metis_core::dal::database::models::Document>> {
78        // For listing all documents, we can query each type
79        let mut all_docs = Vec::new();
80
81        // Collect all document types
82        for doc_type in ["vision", "strategy", "initiative", "task", "adr"] {
83            let mut docs = repo.find_by_type(doc_type)?;
84            all_docs.append(&mut docs);
85        }
86
87        // Filter out archived documents unless requested
88        if !self.include_archived {
89            all_docs.retain(|doc| !doc.archived);
90        }
91
92        // Sort by updated_at descending
93        all_docs.sort_by(|a, b| {
94            b.updated_at
95                .partial_cmp(&a.updated_at)
96                .unwrap_or(std::cmp::Ordering::Equal)
97        });
98
99        Ok(all_docs)
100    }
101
102    fn display_documents(&self, documents: &[metis_core::dal::database::models::Document]) {
103        println!(
104            "\n{:<15} {:<30} {:<15} {:<15} {:<20}",
105            "TYPE", "TITLE", "PHASE", "ID", "UPDATED"
106        );
107        println!("{}", "-".repeat(95));
108
109        for doc in documents {
110            let updated = chrono::DateTime::from_timestamp(doc.updated_at as i64, 0)
111                .map(|dt| dt.format("%Y-%m-%d %H:%M").to_string())
112                .unwrap_or_else(|| "Unknown".to_string());
113
114            println!(
115                "{:<15} {:<30} {:<15} {:<15} {:<20}",
116                doc.document_type,
117                self.truncate_string(&doc.title, 28),
118                doc.phase,
119                self.truncate_string(&doc.id, 13),
120                updated
121            );
122        }
123
124        println!("\nTotal: {} documents", documents.len());
125    }
126
127    fn truncate_string(&self, s: &str, max_len: usize) -> String {
128        if s.len() <= max_len {
129            s.to_string()
130        } else {
131            format!("{}...", &s[..max_len.saturating_sub(3)])
132        }
133    }
134}
135
136#[cfg(test)]
137mod tests {
138    use super::*;
139    use crate::commands::InitCommand;
140    use tempfile::tempdir;
141
142    #[tokio::test]
143    async fn test_list_command_no_workspace() {
144        let temp_dir = tempdir().unwrap();
145        let original_dir = std::env::current_dir().ok();
146
147        // Change to temp directory without workspace
148        if std::env::set_current_dir(temp_dir.path()).is_err() {
149            return; // Skip test if we can't change directory
150        }
151
152        let cmd = ListCommand {
153            document_type: None,
154            phase: None,
155            all: false,
156            include_archived: false,
157        };
158
159        let result = cmd.execute().await;
160
161        // Always restore original directory first
162        if let Some(original) = original_dir {
163            let _ = std::env::set_current_dir(&original);
164        }
165
166        assert!(result.is_err());
167        assert!(result
168            .unwrap_err()
169            .to_string()
170            .contains("Not in a Metis workspace"));
171    }
172
173    #[tokio::test]
174    async fn test_list_command_empty_workspace() {
175        let temp_dir = tempdir().unwrap();
176        let original_dir = std::env::current_dir().ok();
177
178        // Change to temp directory
179        std::env::set_current_dir(temp_dir.path()).unwrap();
180
181        // Create workspace
182        let init_cmd = InitCommand {
183            name: Some("Test Project".to_string()),
184            preset: None,
185            strategies: None,
186            initiatives: None,
187            prefix: None,
188        };
189        init_cmd.execute().await.unwrap();
190
191        let cmd = ListCommand {
192            document_type: None,
193            phase: None,
194            all: true,
195            include_archived: false,
196        };
197
198        let result = cmd.execute().await;
199
200        // Always restore original directory first
201        if let Some(original) = original_dir {
202            let _ = std::env::set_current_dir(&original);
203        }
204
205        // Should succeed but show no documents (except the vision.md created by init)
206        assert!(result.is_ok());
207    }
208}