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}