1use std::collections::BTreeMap;
42use std::io::Write;
43use std::path::{Path, PathBuf};
44
45use crate::commands::create;
46use crate::config::Config;
47use crate::error::{Result, TixError};
48use crate::model::Status;
49use crate::storage::Store;
50use crate::triage::import_policy::{self, ImportPolicy};
51
52pub const LABEL: &str = "speckit";
55
56#[derive(Debug, Clone, PartialEq, Eq)]
58pub struct Task {
59 pub id: String,
61 pub description: String,
63 pub raw: String,
65 pub done: bool,
67}
68
69#[derive(Debug, Clone, PartialEq, Eq)]
71pub struct Feature {
72 pub dir: String,
74 pub name: String,
76 pub tasks_path: String,
78 pub tasks: Vec<Task>,
80}
81
82#[derive(Debug, Clone, PartialEq, Eq)]
84pub struct Artifact {
85 pub dir: String,
87 pub slug: String,
89 pub body: String,
91}
92
93#[derive(Debug, Clone, Default, PartialEq, Eq)]
95pub struct SpecKitArtifacts {
96 pub features: Vec<Feature>,
97 pub bugs: Vec<Artifact>,
98 pub assessments: Vec<Artifact>,
99}
100
101#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
103pub struct MirrorReport {
104 pub features: usize,
106 pub tasks: usize,
108 pub bugs: usize,
110 pub assessments: usize,
112 pub skipped: usize,
114}
115
116impl MirrorReport {
117 #[must_use]
119 pub fn created(&self) -> usize {
120 self.features + self.tasks + self.bugs + self.assessments
121 }
122
123 #[must_use]
125 pub fn summary(&self) -> String {
126 format!(
127 "imported {} issue(s) from Spec Kit: {} feature(s), {} task(s), {} bug(s), \
128 {} assessment(s); {} already mirrored\n",
129 self.created(),
130 self.features,
131 self.tasks,
132 self.bugs,
133 self.assessments,
134 self.skipped
135 )
136 }
137}
138
139#[must_use]
142pub fn report_text(report: &MirrorReport) -> String {
143 if report.created() == 0 && report.skipped == 0 {
144 "nothing to import\n".to_string()
145 } else if report.created() == 0 {
146 format!("nothing to import ({} already mirrored)\n", report.skipped)
147 } else {
148 report.summary()
149 }
150}
151
152fn resolve_root() -> Result<PathBuf> {
155 let cwd = std::env::current_dir()?;
156 Config::project_root(&cwd).ok_or_else(|| {
157 TixError::InvalidInput(
158 "no tix.toml found from the current directory — run `tix import --target speckit` \
159 from the project root"
160 .to_string(),
161 )
162 })
163}
164
165pub fn import_core(config: &Config, store: &Store, policy: ImportPolicy) -> Result<String> {
168 let root = resolve_root()?;
169 let report = mirror(config, store, &root, policy)?;
170 Ok(report_text(&report))
171}
172
173pub fn run(
176 config: &Config,
177 store: &Store,
178 root: &Path,
179 policy: ImportPolicy,
180 out: &mut dyn Write,
181) -> Result<MirrorReport> {
182 let report = mirror(config, store, root, policy)?;
183 out.write_all(report_text(&report).as_bytes())?;
184 Ok(report)
185}
186
187pub fn mirror(
189 config: &Config,
190 store: &Store,
191 root: &Path,
192 policy: ImportPolicy,
193) -> Result<MirrorReport> {
194 let artifacts = discover(root)?;
195
196 let router = import_policy::resolve(config, policy)?;
200 let requested = config
201 .default_config()
202 .columns
203 .first()
204 .cloned()
205 .unwrap_or_else(|| "todo".to_string());
206 let landed = router.place(LABEL, &requested).landed;
207
208 let mut known: BTreeMap<String, u32> = BTreeMap::new();
212 for issue in store.list_all()? {
213 for file in &issue.files {
214 known.entry(file.clone()).or_insert(issue.number);
215 }
216 }
217
218 let mut report = MirrorReport::default();
219
220 for feature in &artifacts.features {
221 let parent_number = match known.get(&feature.dir).copied() {
222 Some(number) => {
223 report.skipped += 1;
224 number
225 }
226 None => {
227 let body = format!(
228 "Mirrored from Spec Kit feature `{dir}`.\n\nTasks source: `{tasks}`.",
229 dir = feature.dir,
230 tasks = feature.tasks_path,
231 );
232 let issue = create_mirror(
233 config,
234 store,
235 &feature.name,
236 &body,
237 &landed,
238 &feature.dir,
239 None,
240 )?;
241 known.insert(feature.dir.clone(), issue.number);
242 report.features += 1;
243 issue.number
244 }
245 };
246
247 for task in &feature.tasks {
248 let key = format!("{}#{}", feature.tasks_path, task.id);
249 if known.contains_key(&key) {
250 report.skipped += 1;
251 continue;
252 }
253 let title = if task.description.is_empty() {
254 task.id.clone()
255 } else {
256 format!("{}: {}", task.id, task.description)
257 };
258 let body = format!(
259 "Spec Kit task `{id}` from `{path}`.\n\n```text\n{raw}\n```",
260 id = task.id,
261 path = feature.tasks_path,
262 raw = task.raw,
263 );
264 let parent = parent_number.to_string();
265 let issue = create_mirror(config, store, &title, &body, &landed, &key, Some(&parent))?;
266 known.insert(key, issue.number);
267 report.tasks += 1;
268 }
269 }
270
271 for (artifacts, counter) in [
272 (&artifacts.bugs, &mut report.bugs),
273 (&artifacts.assessments, &mut report.assessments),
274 ] {
275 for artifact in artifacts {
276 if known.contains_key(&artifact.dir) {
277 report.skipped += 1;
278 continue;
279 }
280 let title = artifact.slug.clone();
281 let issue = create_mirror(
282 config,
283 store,
284 &title,
285 &artifact.body,
286 &landed,
287 &artifact.dir,
288 None,
289 )?;
290 known.insert(artifact.dir.clone(), issue.number);
291 *counter += 1;
292 }
293 }
294
295 Ok(report)
296}
297
298fn create_mirror(
302 config: &Config,
303 store: &Store,
304 title: &str,
305 body: &str,
306 landed: &str,
307 source: &str,
308 parent: Option<&str>,
309) -> Result<crate::model::Issue> {
310 create::create(
311 config,
312 store,
313 title,
314 Some(body),
315 Some(Status::new(landed.to_string())),
316 None, None, Some(LABEL), Some(source), None, None, None, None, None, None, parent, None, None, )
330}
331
332pub fn discover(root: &Path) -> Result<SpecKitArtifacts> {
334 let specify = root.join(".specify");
335 if !specify.is_dir() {
336 return Err(TixError::InvalidInput(format!(
337 "no `.specify/` directory at {} — `tix import --target speckit` needs a Spec Kit \
338 project root",
339 root.display()
340 )));
341 }
342
343 let active = active_feature_dir(root);
344 let mut feature_dirs: BTreeMap<String, PathBuf> = BTreeMap::new();
345 for (name, path) in subdirs(&root.join("specs"))? {
346 let rel = format!("specs/{name}");
347 let is_active = active.as_deref() == Some(rel.as_str());
348 if path.join("tasks.md").is_file() || is_active {
349 feature_dirs.insert(rel, path);
350 }
351 }
352 if let Some(active) = active.as_deref() {
353 let path = root.join(active);
354 if path.is_dir() {
355 feature_dirs.entry(active.to_string()).or_insert(path);
356 }
357 }
358
359 let mut features = Vec::new();
360 for (dir, path) in feature_dirs {
361 let name = path
362 .file_name()
363 .map(|n| n.to_string_lossy().to_string())
364 .unwrap_or_else(|| dir.clone());
365 let tasks_path = format!("{dir}/tasks.md");
366 let tasks = match std::fs::read_to_string(path.join("tasks.md")) {
367 Ok(markdown) => parse_tasks(&markdown),
368 Err(_) => Vec::new(),
369 };
370 features.push(Feature {
371 dir,
372 name,
373 tasks_path,
374 tasks,
375 });
376 }
377
378 let bugs = collect_artifacts(&specify, "bugs")?;
379 let assessments = collect_artifacts(&specify, "assessments")?;
380
381 Ok(SpecKitArtifacts {
382 features,
383 bugs,
384 assessments,
385 })
386}
387
388fn collect_artifacts(specify: &Path, base: &str) -> Result<Vec<Artifact>> {
390 let mut out = Vec::new();
391 for (slug, path) in subdirs(&specify.join(base))? {
392 out.push(Artifact {
393 dir: format!(".specify/{base}/{slug}"),
394 body: read_markdown_dir(&path)?,
395 slug,
396 });
397 }
398 Ok(out)
399}
400
401fn active_feature_dir(root: &Path) -> Option<String> {
404 let raw = std::fs::read_to_string(root.join(".specify/feature.json")).ok()?;
405 let value: serde_json::Value = serde_json::from_str(&raw).ok()?;
406 let dir = value
407 .get("feature_directory")?
408 .as_str()?
409 .trim()
410 .trim_end_matches('/');
411 if dir.is_empty() {
412 None
413 } else {
414 Some(dir.to_string())
415 }
416}
417
418fn subdirs(dir: &Path) -> Result<Vec<(String, PathBuf)>> {
420 let mut out = Vec::new();
421 if !dir.is_dir() {
422 return Ok(out);
423 }
424 for entry in std::fs::read_dir(dir)? {
425 let entry = entry?;
426 if !entry.file_type()?.is_dir() {
427 continue;
428 }
429 let name = entry.file_name().to_string_lossy().to_string();
430 if name.starts_with('.') {
431 continue;
432 }
433 out.push((name, entry.path()));
434 }
435 out.sort_by(|a, b| a.0.cmp(&b.0));
436 Ok(out)
437}
438
439fn read_markdown_dir(dir: &Path) -> Result<String> {
442 let mut files = Vec::new();
443 if dir.is_dir() {
444 for entry in std::fs::read_dir(dir)? {
445 let path = entry?.path();
446 if path.extension().and_then(|e| e.to_str()) == Some("md") {
447 files.push(path);
448 }
449 }
450 }
451 files.sort();
452 let mut out = String::new();
453 for path in files {
454 let name = path
455 .file_name()
456 .map(|n| n.to_string_lossy().to_string())
457 .unwrap_or_default();
458 if !out.is_empty() {
459 out.push('\n');
460 }
461 out.push_str(&format!("## {name}\n\n"));
462 out.push_str(&std::fs::read_to_string(&path)?);
463 if !out.ends_with('\n') {
464 out.push('\n');
465 }
466 }
467 Ok(out)
468}
469
470#[must_use]
476pub fn parse_tasks(markdown: &str) -> Vec<Task> {
477 let mut tasks = Vec::new();
478 for line in markdown.lines() {
479 let trimmed = line.trim();
480 let (done, rest) = if let Some(rest) = trimmed.strip_prefix("- [ ]") {
481 (false, rest)
482 } else if let Some(rest) = trimmed
483 .strip_prefix("- [x]")
484 .or_else(|| trimmed.strip_prefix("- [X]"))
485 {
486 (true, rest)
487 } else {
488 continue;
489 };
490 let rest = rest.trim_start();
491 let mut parts = rest.splitn(2, char::is_whitespace);
492 let id = parts.next().unwrap_or("");
493 if !is_task_id(id) {
494 continue;
495 }
496 let mut description = parts.next().unwrap_or("").trim_start();
497 loop {
498 let current = description.trim_start();
499 if let Some(stripped) = current.strip_prefix("[P]") {
500 description = stripped;
501 } else if let Some(stripped) = strip_story_tag(current) {
502 description = stripped;
503 } else {
504 description = current;
505 break;
506 }
507 }
508 tasks.push(Task {
509 id: id.to_string(),
510 description: description.trim().to_string(),
511 raw: trimmed.to_string(),
512 done,
513 });
514 }
515 tasks
516}
517
518fn is_task_id(token: &str) -> bool {
520 token.len() >= 2 && token.starts_with('T') && token[1..].bytes().all(|b| b.is_ascii_digit())
521}
522
523fn strip_story_tag(text: &str) -> Option<&str> {
525 let text = text.strip_prefix("[US")?;
526 let end = text.find(']')?;
527 if !text[..end].bytes().all(|b| b.is_ascii_digit()) || end == 0 {
528 return None;
529 }
530 Some(&text[end + 1..])
531}
532
533#[cfg(test)]
534mod tests {
535 #[allow(clippy::wildcard_imports)]
536 use super::*;
537 use std::fs;
538 use tempfile::TempDir;
539
540 #[test]
541 fn parse_tasks_reads_the_checklist_format() {
542 let md = "\
543# Tasks
544
545## Phase 1: Setup
546
547- [ ] T001 Create project structure
548- [x] T002 [P] Add dependencies in Cargo.toml
549- [ ] T003 [US1] [P] Implement the login form in src/login.rs
550- Not a task line
551- [ ] no id here
552- [X] T004 Done one
553";
554 let tasks = parse_tasks(md);
555 assert_eq!(tasks.len(), 4);
556 assert_eq!(tasks[0].id, "T001");
557 assert_eq!(tasks[0].description, "Create project structure");
558 assert!(!tasks[0].done);
559 assert_eq!(tasks[1].id, "T002");
560 assert_eq!(tasks[1].description, "Add dependencies in Cargo.toml");
561 assert!(tasks[1].done);
562 assert_eq!(tasks[2].id, "T003");
563 assert_eq!(
564 tasks[2].description,
565 "Implement the login form in src/login.rs"
566 );
567 assert_eq!(tasks[3].id, "T004");
568 assert!(tasks[3].done);
569 }
570
571 #[test]
572 fn discover_reads_features_bugs_and_assessments() {
573 let tmp = TempDir::new().unwrap();
574 let root = tmp.path();
575 fs::create_dir_all(root.join(".specify/memory")).unwrap();
576 fs::write(
577 root.join(".specify/feature.json"),
578 r#"{ "feature_directory": "specs/004-empty" }"#,
579 )
580 .unwrap();
581 fs::create_dir_all(root.join("specs/003-user-auth")).unwrap();
583 fs::write(
584 root.join("specs/003-user-auth/tasks.md"),
585 "- [ ] T001 Do the thing\n- [ ] T002 [P] Do another\n",
586 )
587 .unwrap();
588 fs::create_dir_all(root.join("specs/004-empty")).unwrap();
590 fs::write(root.join("specs/004-empty/spec.md"), "# spec\n").unwrap();
591 fs::create_dir_all(root.join("specs/notes")).unwrap();
593 fs::create_dir_all(root.join(".specify/bugs/login-crash")).unwrap();
595 fs::write(
596 root.join(".specify/bugs/login-crash/assess.md"),
597 "## Assessment\n\nboom\n",
598 )
599 .unwrap();
600 fs::create_dir_all(root.join(".specify/assessments/offline-mode")).unwrap();
601 fs::write(
602 root.join(".specify/assessments/offline-mode/decide.md"),
603 "go\n",
604 )
605 .unwrap();
606
607 let artifacts = discover(root).unwrap();
608 let names: Vec<&str> = artifacts.features.iter().map(|f| f.dir.as_str()).collect();
609 assert!(names.contains(&"specs/003-user-auth"));
611 assert!(names.contains(&"specs/004-empty"));
612 assert!(!names.contains(&"specs/notes"));
613 let feature = artifacts
614 .features
615 .iter()
616 .find(|f| f.dir == "specs/003-user-auth")
617 .unwrap();
618 assert_eq!(feature.tasks.len(), 2);
619 assert_eq!(feature.tasks_path, "specs/003-user-auth/tasks.md");
620 assert_eq!(artifacts.bugs.len(), 1);
621 assert_eq!(artifacts.bugs[0].dir, ".specify/bugs/login-crash");
622 assert!(artifacts.bugs[0].body.contains("## assess.md"));
623 assert!(artifacts.bugs[0].body.contains("boom"));
624 assert_eq!(artifacts.assessments.len(), 1);
625 assert_eq!(artifacts.assessments[0].slug, "offline-mode");
626 }
627
628 #[test]
629 fn discover_without_specify_is_an_invalid_input_error() {
630 let tmp = TempDir::new().unwrap();
631 let err = discover(tmp.path()).unwrap_err();
632 assert!(matches!(err, TixError::InvalidInput(_)), "{err}");
633 assert!(err.to_string().contains(".specify/"));
634 }
635}