Skip to main content

tix/speckit/
mod.rs

1//! `tix import --target speckit`: mirror a local **GitHub Spec Kit** project
2//! onto the tix board.
3//!
4//! Spec Kit (`specify-cli`) writes its artifacts into the repository: a
5//! `.specify/` project root, feature directories under `specs/NNN-<name>/`
6//! (`tasks.md` carrying `- [ ] T001 ...` task lines), bug reports under
7//! `.specify/bugs/<slug>/`, and idea assessments under
8//! `.specify/assessments/<slug>/`. Unlike the `glab`/`linear-cli` targets,
9//! there is no remote and no subprocess: everything is local Markdown, so this
10//! module reads the tree directly and writes tix issues through the same
11//! [`crate::commands::create::create`] core the CLI and MCP use.
12//!
13//! ## What it creates
14//!
15//! - One **parent** issue per feature (`specs/NNN-<name>/`) — `files` records
16//!   the feature directory.
17//! - One **child** issue per `T0xx` task in the feature's `tasks.md`, with
18//!   `parent:` set to the feature issue — `files` records
19//!   `specs/NNN-<name>/tasks.md#T0xx`.
20//! - One issue per bug directory and per assessment directory — `files` records
21//!   the directory.
22//!
23//! Every created issue carries the [`LABEL`] label.
24//!
25//! ## Idempotency
26//!
27//! The source artifact path recorded in an issue's `files` field is the
28//! identity. Before creating anything this module builds a
29//! `source path -> issue number` map from every existing issue (archived ones
30//! included), and an artifact whose key already appears is skipped. A second
31//! run therefore creates nothing; the per-artifact keys are distinct
32//! (`specs/x` for the feature, `specs/x/tasks.md#T001` for a task), so a
33//! feature and its tasks never collide on the same key.
34//!
35//! ## Relationship to `/speckit.taskstoissues`
36//!
37//! Spec Kit's own `taskstoissues` command targets GitHub only. This is the tix
38//! equivalent, and the AGENTS.md section generated by `tix init-agents` points
39//! agents at it (see `docs/spec-kit-integration.md`).
40
41use 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
52/// The label every mirrored issue carries, so `tix list` can separate Spec Kit
53/// work from the rest of the board.
54pub const LABEL: &str = "speckit";
55
56/// One `tasks.md` task line.
57#[derive(Debug, Clone, PartialEq, Eq)]
58pub struct Task {
59    /// The task id, e.g. `T001`.
60    pub id: String,
61    /// The description after the id and any `[P]` / `[USn]` tags.
62    pub description: String,
63    /// The original line, stripped of leading/trailing whitespace.
64    pub raw: String,
65    /// Whether the checkbox was checked (`- [x]`) when the task was read.
66    pub done: bool,
67}
68
69/// One feature directory under `specs/`.
70#[derive(Debug, Clone, PartialEq, Eq)]
71pub struct Feature {
72    /// Repository-relative directory, e.g. `specs/003-user-auth`.
73    pub dir: String,
74    /// The directory basename, e.g. `003-user-auth`.
75    pub name: String,
76    /// Repository-relative `tasks.md`, e.g. `specs/003-user-auth/tasks.md`.
77    pub tasks_path: String,
78    /// The parsed tasks (empty when the feature has no `tasks.md` yet).
79    pub tasks: Vec<Task>,
80}
81
82/// A bug or assessment directory mirrored as a single issue.
83#[derive(Debug, Clone, PartialEq, Eq)]
84pub struct Artifact {
85    /// Repository-relative directory, e.g. `.specify/bugs/login-crash`.
86    pub dir: String,
87    /// The directory basename, e.g. `login-crash`.
88    pub slug: String,
89    /// The concatenated report Markdown.
90    pub body: String,
91}
92
93/// Everything `discover` found in one Spec Kit project root.
94#[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/// What one mirror run created and skipped.
102#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
103pub struct MirrorReport {
104    /// Parent issues created.
105    pub features: usize,
106    /// Child task issues created.
107    pub tasks: usize,
108    /// Bug issues created.
109    pub bugs: usize,
110    /// Assessment issues created.
111    pub assessments: usize,
112    /// Artifacts already mirrored (key present on an existing issue).
113    pub skipped: usize,
114}
115
116impl MirrorReport {
117    /// Total issues created across every area.
118    #[must_use]
119    pub fn created(&self) -> usize {
120        self.features + self.tasks + self.bugs + self.assessments
121    }
122
123    /// The one-line stdout report.
124    #[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/// The stdout report for a mirror run — the single writer for both the CLI
140/// (`run`) and the string core (`import_core`), so they cannot drift.
141#[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
152/// Resolve the Spec Kit project root from the current directory: the directory
153/// holding `tix.toml`, which is also where `.specify/` lives.
154fn 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
165/// The import core for `--target speckit`: return the stdout report as a
166/// `String`, mirroring the remote-import core so the CLI and MCP share it.
167pub 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
173/// Mirror the Spec Kit project rooted at `root`, writing created issues through
174/// `store`. `out` receives the report.
175pub 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
187/// The mirror core: discover the artifacts and create the missing issues.
188pub fn mirror(
189    config: &Config,
190    store: &Store,
191    root: &Path,
192    policy: ImportPolicy,
193) -> Result<MirrorReport> {
194    let artifacts = discover(root)?;
195
196    // The landing column: the import policy decides it exactly as it does for a
197    // remote import — the "requested" column is the board's first column, which
198    // is where `create` puts a new issue when no status is named.
199    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    // Identity map: every source path already recorded by an issue (archived
209    // included) -> that issue's number. Distinct keys per artifact kind keep a
210    // feature and its tasks from colliding.
211    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
298/// Create one mirrored issue through the shared create core, so defaults,
299/// validation, the entry stamp and the parent History entry are exactly those
300/// of `tix create`.
301fn 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,         // priority
317        None,         // assignee
318        Some(LABEL),  // labels
319        Some(source), // files
320        None,         // estimate
321        None,         // cycles (create applies the default)
322        None,         // milestones
323        None,         // reviewer
324        None,         // start_date
325        None,         // due_date
326        parent,       // parent
327        None,         // related_issues
328        None,         // type
329    )
330}
331
332/// Read a Spec Kit project root into the artifacts to mirror.
333pub 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
388/// Collect every `<base>/<slug>/` directory as one artifact, sorted by slug.
389fn 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
401/// The active feature directory recorded by `/speckit.specify` in
402/// `.specify/feature.json` (`{ "feature_directory": "specs/003-..." }`).
403fn 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
418/// Direct child directories of `dir`, sorted by name, hidden ones skipped.
419fn 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
439/// Concatenate the `.md` files of a directory (sorted by name) into one body,
440/// each under its filename heading.
441fn 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/// Parse the task lines of a `tasks.md`.
471///
472/// The strict checklist format is `- [ ] T001 [P] [US1] Description` (Spec
473/// Kit's task-generation rule). Only lines whose first token after the
474/// checkbox is a `T`-numbered id are tasks; prose and headings are ignored.
475#[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
518/// A task id is `T` followed by at least one ASCII digit (`T001`).
519fn 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
523/// Strip a leading `[USn]` story tag, if present.
524fn 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        // Feature with tasks.
582        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        // Active-only feature with no tasks.md yet.
589        fs::create_dir_all(root.join("specs/004-empty")).unwrap();
590        fs::write(root.join("specs/004-empty/spec.md"), "# spec\n").unwrap();
591        // A non-feature dir that must be ignored.
592        fs::create_dir_all(root.join("specs/notes")).unwrap();
593        // Bug + assessment.
594        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        // 004 is the active feature but has no tasks.md; 003 has tasks; notes is ignored.
610        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}