Skip to main content

metis_docs_cli/commands/
list.rs

1use crate::workspace;
2use anyhow::Result;
3use clap::{Args, ValueEnum};
4use metis_core::{Application, Database, Result as MetisResult};
5use serde::Serialize;
6
7/// Output format for CLI commands
8#[derive(Debug, Clone, Copy, ValueEnum, Default)]
9pub enum OutputFormat {
10    /// Human-readable table (default)
11    #[default]
12    Table,
13    /// Compact single-line per document for scripts
14    Compact,
15    /// JSON output for programmatic use
16    Json,
17}
18
19#[derive(Args)]
20pub struct ListCommand {
21    /// Document type to filter by (vision, strategy, initiative, task, adr)
22    #[arg(short = 't', long)]
23    pub document_type: Option<String>,
24
25    /// Phase to filter by (draft, active, completed, etc.)
26    #[arg(short = 'p', long)]
27    pub phase: Option<String>,
28
29    /// Show all documents regardless of type
30    #[arg(short = 'a', long)]
31    pub all: bool,
32
33    /// Include archived documents in the list
34    #[arg(long)]
35    pub include_archived: bool,
36
37    /// Output format (table, compact, json)
38    #[arg(short = 'f', long, value_enum, default_value = "table")]
39    pub format: OutputFormat,
40}
41
42/// JSON-serializable document for output
43#[derive(Serialize)]
44struct DocumentOutput {
45    #[serde(rename = "type")]
46    doc_type: String,
47    code: String,
48    title: String,
49    phase: String,
50}
51
52impl ListCommand {
53    pub async fn execute(&self) -> Result<()> {
54        // 1. Validate we're in a metis workspace
55        let (workspace_exists, metis_dir) = workspace::has_metis_vault();
56        if !workspace_exists {
57            anyhow::bail!("Not in a Metis workspace. Run 'metis init' to create one.");
58        }
59        let metis_dir = metis_dir.unwrap();
60
61        // 2. Sync before reading to catch external edits
62        let db_path = metis_dir.join("metis.db");
63        let database = Database::new(db_path.to_str().unwrap())
64            .map_err(|e| anyhow::anyhow!("Failed to open database for sync: {}", e))?;
65        let app = Application::new(database);
66        app.sync_directory(&metis_dir)
67            .await
68            .map_err(|e| anyhow::anyhow!("Failed to sync workspace: {}", e))?;
69
70        // 3. Connect to database
71        let db = Database::new(db_path.to_str().unwrap())
72            .map_err(|e| anyhow::anyhow!("Database connection failed: {}", e))?;
73        let mut repo = db.into_repository();
74
75        // 4. Query documents based on filters
76        let documents = if self.all {
77            // Show all documents
78            self.list_all_documents(&mut repo).await?
79        } else if let Some(doc_type) = &self.document_type {
80            if let Some(phase) = &self.phase {
81                // Filter by both type and phase
82                repo.find_by_type_and_phase(doc_type, phase)
83                    .map_err(|e| anyhow::anyhow!("Database query failed: {}", e))?
84            } else {
85                // Filter by type only
86                repo.find_by_type(doc_type)
87                    .map_err(|e| anyhow::anyhow!("Database query failed: {}", e))?
88            }
89        } else if let Some(phase) = &self.phase {
90            // Filter by phase only
91            repo.find_by_phase(phase)
92                .map_err(|e| anyhow::anyhow!("Database query failed: {}", e))?
93        } else {
94            // Default: show all documents
95            self.list_all_documents(&mut repo).await?
96        };
97
98        // 5. Display results based on format
99        if documents.is_empty() {
100            match self.format {
101                OutputFormat::Json => println!("[]"),
102                _ => println!("No documents found matching the criteria."),
103            }
104            return Ok(());
105        }
106
107        match self.format {
108            OutputFormat::Table => self.display_table(&documents),
109            OutputFormat::Compact => self.display_compact(&documents),
110            OutputFormat::Json => self.display_json(&documents),
111        }
112
113        Ok(())
114    }
115
116    async fn list_all_documents(
117        &self,
118        repo: &mut metis_core::dal::database::repository::DocumentRepository,
119    ) -> MetisResult<Vec<metis_core::dal::database::models::Document>> {
120        // For listing all documents, we can query each type
121        let mut all_docs = Vec::new();
122
123        // Collect all document types in display order (matching MCP)
124        for doc_type in ["vision", "strategy", "initiative", "task", "adr"] {
125            let mut docs = repo.find_by_type(doc_type)?;
126            all_docs.append(&mut docs);
127        }
128
129        // Filter out archived documents unless requested
130        if !self.include_archived {
131            all_docs.retain(|doc| !doc.archived);
132        }
133
134        // Sort by type order, then by short_code (matching MCP behavior)
135        let type_order = |t: &str| match t {
136            "vision" => 0,
137            "strategy" => 1,
138            "initiative" => 2,
139            "task" => 3,
140            "adr" => 4,
141            _ => 5,
142        };
143
144        all_docs.sort_by(|a, b| {
145            let type_cmp = type_order(&a.document_type).cmp(&type_order(&b.document_type));
146            if type_cmp != std::cmp::Ordering::Equal {
147                type_cmp
148            } else {
149                a.short_code.cmp(&b.short_code)
150            }
151        });
152
153        Ok(all_docs)
154    }
155
156    /// Display documents as a human-readable table
157    /// Columns match MCP list_documents: Type, Code, Title, Phase
158    fn display_table(&self, documents: &[metis_core::dal::database::models::Document]) {
159        println!(
160            "\n{:<12} {:<14} {:<50} {:<12}",
161            "Type", "Code", "Title", "Phase"
162        );
163        println!("{}", "-".repeat(90));
164
165        for doc in documents {
166            println!(
167                "{:<12} {:<14} {:<50} {:<12}",
168                doc.document_type,
169                doc.short_code,
170                self.truncate_string(&doc.title, 48),
171                doc.phase
172            );
173        }
174
175        println!("\nTotal: {} documents", documents.len());
176    }
177
178    /// Display documents in compact format (one line per document)
179    /// Format: CODE PHASE TITLE
180    fn display_compact(&self, documents: &[metis_core::dal::database::models::Document]) {
181        for doc in documents {
182            println!("{} {} {}", doc.short_code, doc.phase, doc.title);
183        }
184    }
185
186    /// Display documents as JSON array
187    fn display_json(&self, documents: &[metis_core::dal::database::models::Document]) {
188        let output: Vec<DocumentOutput> = documents
189            .iter()
190            .map(|doc| DocumentOutput {
191                doc_type: doc.document_type.clone(),
192                code: doc.short_code.clone(),
193                title: doc.title.clone(),
194                phase: doc.phase.clone(),
195            })
196            .collect();
197
198        match serde_json::to_string_pretty(&output) {
199            Ok(json) => println!("{}", json),
200            Err(e) => eprintln!("Error serializing to JSON: {}", e),
201        }
202    }
203
204    fn truncate_string(&self, s: &str, max_len: usize) -> String {
205        if s.len() <= max_len {
206            s.to_string()
207        } else {
208            format!("{}...", &s[..max_len.saturating_sub(3)])
209        }
210    }
211}
212
213#[cfg(test)]
214mod tests {
215    use super::*;
216    use crate::commands::InitCommand;
217    use tempfile::tempdir;
218
219    #[tokio::test]
220    async fn test_list_command_no_workspace() {
221        let temp_dir = tempdir().unwrap();
222        let original_dir = std::env::current_dir().ok();
223
224        // Change to temp directory without workspace
225        if std::env::set_current_dir(temp_dir.path()).is_err() {
226            return; // Skip test if we can't change directory
227        }
228
229        let cmd = ListCommand {
230            document_type: None,
231            phase: None,
232            all: false,
233            include_archived: false,
234            format: OutputFormat::Table,
235        };
236
237        let result = cmd.execute().await;
238
239        // Always restore original directory first
240        if let Some(original) = original_dir {
241            let _ = std::env::set_current_dir(&original);
242        }
243
244        assert!(result.is_err());
245        assert!(result
246            .unwrap_err()
247            .to_string()
248            .contains("Not in a Metis workspace"));
249    }
250
251    #[tokio::test]
252    async fn test_list_command_empty_workspace() {
253        let temp_dir = tempdir().unwrap();
254        let original_dir = std::env::current_dir().ok();
255
256        // Change to temp directory
257        std::env::set_current_dir(temp_dir.path()).unwrap();
258
259        // Create workspace
260        let init_cmd = InitCommand {
261            name: Some("Test Project".to_string()),
262            preset: None,
263            strategies: None,
264            initiatives: None,
265            prefix: None,
266        };
267        init_cmd.execute().await.unwrap();
268
269        let cmd = ListCommand {
270            document_type: None,
271            phase: None,
272            all: true,
273            include_archived: false,
274            format: OutputFormat::Table,
275        };
276
277        let result = cmd.execute().await;
278
279        // Always restore original directory first
280        if let Some(original) = original_dir {
281            let _ = std::env::set_current_dir(&original);
282        }
283
284        // Should succeed but show no documents (except the vision.md created by init)
285        assert!(result.is_ok());
286    }
287}