Skip to main content

task_graph_mcp/cli/
import.rs

1//! Import subcommand for task-graph CLI
2//!
3//! Imports task data from a structured JSON export file back into
4//! the database.
5
6use clap::Args;
7use std::path::PathBuf;
8
9/// Arguments for the import subcommand
10#[derive(Args, Debug)]
11pub struct ImportArgs {
12    /// Path to the export file to import
13    #[arg(value_name = "FILE")]
14    pub file: PathBuf,
15
16    /// Validate import without modifying database
17    ///
18    /// Parses the file, validates schema compatibility, and reports
19    /// what would be imported without making any changes.
20    #[arg(long)]
21    pub dry_run: bool,
22
23    /// Merge mode: add missing items, skip existing
24    ///
25    /// By default, import replaces all project data. With --merge:
26    /// - Tasks: skip if ID already exists, insert if new
27    /// - Dependencies: skip if exact match exists
28    /// - Attachments: configurable via --attachment-mode
29    #[arg(long)]
30    pub merge: bool,
31
32    /// Force overwrite of existing data without prompting
33    ///
34    /// In replace mode (default): skip confirmation prompt
35    /// In merge mode: overwrite conflicts instead of skipping
36    #[arg(long)]
37    pub force: bool,
38
39    /// Enable strict validation mode
40    ///
41    /// Rejects imports with:
42    /// - Circular dependencies (normally just warned)
43    /// - Missing referenced tasks
44    /// - Invalid status values
45    #[arg(long)]
46    pub strict: bool,
47
48    /// Generate fresh IDs and remap all references
49    ///
50    /// When enabled, every task receives a new petname ID and all
51    /// references (dependencies, attachments, tags, state history)
52    /// are updated to use the new IDs. This allows importing the
53    /// same snapshot multiple times without ID collisions.
54    #[arg(long)]
55    pub remap_ids: bool,
56
57    /// Attach imported tree under a parent task
58    ///
59    /// When provided, root tasks in the imported snapshot (those with
60    /// no "contains" dependency pointing to them) will be linked to
61    /// the given parent task via a "contains" dependency. This allows
62    /// importing a snapshot as a subtree of an existing task.
63    #[arg(long, value_name = "TASK_ID")]
64    pub parent: Option<String>,
65}
66
67impl ImportArgs {
68    /// Check if this is a gzipped file based on extension
69    pub fn is_gzipped(&self) -> bool {
70        self.file.extension().is_some_and(|ext| ext == "gz")
71    }
72
73    /// Describe the import mode for logging
74    pub fn import_mode(&self) -> &'static str {
75        if self.dry_run {
76            "dry-run"
77        } else if self.merge {
78            if self.force {
79                "merge-overwrite"
80            } else {
81                "merge-skip"
82            }
83        } else if self.remap_ids {
84            "replace-remap"
85        } else {
86            "replace"
87        }
88    }
89}
90
91#[cfg(test)]
92mod tests {
93    use super::*;
94
95    #[test]
96    fn test_is_gzipped() {
97        let args = ImportArgs {
98            file: PathBuf::from("snapshot.json"),
99            dry_run: false,
100            merge: false,
101            force: false,
102            strict: false,
103            remap_ids: false,
104            parent: None,
105        };
106        assert!(!args.is_gzipped());
107
108        let args = ImportArgs {
109            file: PathBuf::from("snapshot.json.gz"),
110            dry_run: false,
111            merge: false,
112            force: false,
113            strict: false,
114            remap_ids: false,
115            parent: None,
116        };
117        assert!(args.is_gzipped());
118    }
119
120    #[test]
121    fn test_import_mode() {
122        // Dry run
123        let args = ImportArgs {
124            file: PathBuf::from("test.json"),
125            dry_run: true,
126            merge: false,
127            force: false,
128            strict: false,
129            remap_ids: false,
130            parent: None,
131        };
132        assert_eq!(args.import_mode(), "dry-run");
133
134        // Replace (default)
135        let args = ImportArgs {
136            file: PathBuf::from("test.json"),
137            dry_run: false,
138            merge: false,
139            force: false,
140            strict: false,
141            remap_ids: false,
142            parent: None,
143        };
144        assert_eq!(args.import_mode(), "replace");
145
146        // Merge skip
147        let args = ImportArgs {
148            file: PathBuf::from("test.json"),
149            dry_run: false,
150            merge: true,
151            force: false,
152            strict: false,
153            remap_ids: false,
154            parent: None,
155        };
156        assert_eq!(args.import_mode(), "merge-skip");
157
158        // Merge overwrite
159        let args = ImportArgs {
160            file: PathBuf::from("test.json"),
161            dry_run: false,
162            merge: true,
163            force: true,
164            strict: false,
165            remap_ids: false,
166            parent: None,
167        };
168        assert_eq!(args.import_mode(), "merge-overwrite");
169
170        // Remap IDs
171        let args = ImportArgs {
172            file: PathBuf::from("test.json"),
173            dry_run: false,
174            merge: false,
175            force: false,
176            strict: false,
177            remap_ids: true,
178            parent: None,
179        };
180        assert_eq!(args.import_mode(), "replace-remap");
181    }
182}