Skip to main content

task_graph_mcp/cli/
export.rs

1//! Export subcommand for task-graph CLI
2//!
3//! Exports the task database to a structured JSON format that can be
4//! version-controlled, diffed, and re-imported.
5
6use clap::Args;
7use std::path::PathBuf;
8
9/// Arguments for the export subcommand
10#[derive(Args, Debug)]
11pub struct ExportArgs {
12    /// Output file path (default: stdout)
13    #[arg(short, long, value_name = "FILE")]
14    pub output: Option<PathBuf>,
15
16    /// Force gzip compression (auto-detected from .gz extension otherwise)
17    #[arg(long)]
18    pub gzip: bool,
19
20    /// Comma-separated list of tables to export
21    ///
22    /// Available tables: tasks, dependencies, attachments, task_tags,
23    /// task_needed_tags, task_wanted_tags, task_sequence
24    #[arg(long, value_name = "LIST", value_delimiter = ',')]
25    pub tables: Option<Vec<String>>,
26
27    /// Exclude task_sequence table (audit history)
28    #[arg(long)]
29    pub no_history: bool,
30
31    /// Filter out soft-deleted tasks (where deleted_at is set)
32    #[arg(long)]
33    pub exclude_deleted: bool,
34
35    /// Automatically compress if output exceeds this size
36    ///
37    /// Accepts human-readable sizes: 100KB, 1MB, etc.
38    /// If the uncompressed output exceeds this threshold, the output
39    /// will be gzip compressed (and .gz appended to filename if needed).
40    #[arg(long, value_name = "SIZE")]
41    pub compress_threshold: Option<String>,
42}
43
44impl ExportArgs {
45    /// Get the list of tables to export, or None for all tables
46    pub fn tables_to_export(&self) -> Option<Vec<String>> {
47        if self.no_history {
48            // If --no-history is set but --tables is also set, filter out history
49            if let Some(ref tables) = self.tables {
50                Some(
51                    tables
52                        .iter()
53                        .filter(|t| *t != "task_sequence")
54                        .cloned()
55                        .collect(),
56                )
57            } else {
58                // Return all tables except task_sequence
59                Some(vec![
60                    "tasks".to_string(),
61                    "dependencies".to_string(),
62                    "attachments".to_string(),
63                    "task_tags".to_string(),
64                    "task_needed_tags".to_string(),
65                    "task_wanted_tags".to_string(),
66                ])
67            }
68        } else {
69            self.tables.clone()
70        }
71    }
72
73    /// Parse the compress threshold into bytes
74    pub fn compress_threshold_bytes(&self) -> Option<u64> {
75        self.compress_threshold.as_ref().and_then(|s| parse_size(s))
76    }
77
78    /// Determine if output should be compressed based on args and filename
79    pub fn should_compress(&self, output_size: Option<u64>) -> bool {
80        // Explicit --gzip flag always wins
81        if self.gzip {
82            return true;
83        }
84
85        // Check if output filename ends with .gz
86        if let Some(ref path) = self.output
87            && path.extension().is_some_and(|ext| ext == "gz")
88        {
89            return true;
90        }
91
92        // Check against threshold if provided
93        if let (Some(threshold), Some(size)) = (self.compress_threshold_bytes(), output_size) {
94            return size > threshold;
95        }
96
97        false
98    }
99}
100
101/// Parse a human-readable size string into bytes
102///
103/// Supports: B, KB, MB, GB (case-insensitive)
104fn parse_size(s: &str) -> Option<u64> {
105    let s = s.trim().to_uppercase();
106
107    if let Some(num) = s.strip_suffix("GB") {
108        num.trim()
109            .parse::<u64>()
110            .ok()
111            .map(|n| n * 1024 * 1024 * 1024)
112    } else if let Some(num) = s.strip_suffix("MB") {
113        num.trim().parse::<u64>().ok().map(|n| n * 1024 * 1024)
114    } else if let Some(num) = s.strip_suffix("KB") {
115        num.trim().parse::<u64>().ok().map(|n| n * 1024)
116    } else if let Some(num) = s.strip_suffix('B') {
117        num.trim().parse::<u64>().ok()
118    } else {
119        // Try parsing as plain number (bytes)
120        s.parse::<u64>().ok()
121    }
122}
123
124#[cfg(test)]
125mod tests {
126    use super::*;
127
128    #[test]
129    fn test_parse_size() {
130        assert_eq!(parse_size("100"), Some(100));
131        assert_eq!(parse_size("100B"), Some(100));
132        assert_eq!(parse_size("100KB"), Some(100 * 1024));
133        assert_eq!(parse_size("100kb"), Some(100 * 1024));
134        assert_eq!(parse_size("1MB"), Some(1024 * 1024));
135        assert_eq!(parse_size("1GB"), Some(1024 * 1024 * 1024));
136        assert_eq!(parse_size("invalid"), None);
137    }
138
139    #[test]
140    fn test_tables_to_export_no_history() {
141        let args = ExportArgs {
142            output: None,
143            gzip: false,
144            tables: None,
145            no_history: true,
146            exclude_deleted: false,
147            compress_threshold: None,
148        };
149
150        let tables = args.tables_to_export().unwrap();
151        assert!(!tables.contains(&"task_sequence".to_string()));
152        assert!(tables.contains(&"tasks".to_string()));
153    }
154
155    #[test]
156    fn test_should_compress() {
157        // Test explicit gzip flag
158        let args = ExportArgs {
159            output: None,
160            gzip: true,
161            tables: None,
162            no_history: false,
163            exclude_deleted: false,
164            compress_threshold: None,
165        };
166        assert!(args.should_compress(None));
167
168        // Test .gz extension detection
169        let args = ExportArgs {
170            output: Some(PathBuf::from("snapshot.json.gz")),
171            gzip: false,
172            tables: None,
173            no_history: false,
174            exclude_deleted: false,
175            compress_threshold: None,
176        };
177        assert!(args.should_compress(None));
178
179        // Test threshold
180        let args = ExportArgs {
181            output: None,
182            gzip: false,
183            tables: None,
184            no_history: false,
185            exclude_deleted: false,
186            compress_threshold: Some("100KB".to_string()),
187        };
188        assert!(!args.should_compress(Some(50 * 1024))); // Under threshold
189        assert!(args.should_compress(Some(150 * 1024))); // Over threshold
190    }
191}