use std::collections::BTreeMap;
use std::io::Write;
use std::path::{Path, PathBuf};
use crate::commands::create;
use crate::config::Config;
use crate::error::{Result, TixError};
use crate::model::Status;
use crate::storage::Store;
use crate::triage::import_policy::{self, ImportPolicy};
pub const LABEL: &str = "speckit";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Task {
pub id: String,
pub description: String,
pub raw: String,
pub done: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Feature {
pub dir: String,
pub name: String,
pub tasks_path: String,
pub tasks: Vec<Task>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Artifact {
pub dir: String,
pub slug: String,
pub body: String,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct SpecKitArtifacts {
pub features: Vec<Feature>,
pub bugs: Vec<Artifact>,
pub assessments: Vec<Artifact>,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct MirrorReport {
pub features: usize,
pub tasks: usize,
pub bugs: usize,
pub assessments: usize,
pub skipped: usize,
}
impl MirrorReport {
#[must_use]
pub fn created(&self) -> usize {
self.features + self.tasks + self.bugs + self.assessments
}
#[must_use]
pub fn summary(&self) -> String {
format!(
"imported {} issue(s) from Spec Kit: {} feature(s), {} task(s), {} bug(s), \
{} assessment(s); {} already mirrored\n",
self.created(),
self.features,
self.tasks,
self.bugs,
self.assessments,
self.skipped
)
}
}
#[must_use]
pub fn report_text(report: &MirrorReport) -> String {
if report.created() == 0 && report.skipped == 0 {
"nothing to import\n".to_string()
} else if report.created() == 0 {
format!("nothing to import ({} already mirrored)\n", report.skipped)
} else {
report.summary()
}
}
fn resolve_root() -> Result<PathBuf> {
let cwd = std::env::current_dir()?;
Config::project_root(&cwd).ok_or_else(|| {
TixError::InvalidInput(
"no tix.toml found from the current directory — run `tix import --target speckit` \
from the project root"
.to_string(),
)
})
}
pub fn import_core(config: &Config, store: &Store, policy: ImportPolicy) -> Result<String> {
let root = resolve_root()?;
let report = mirror(config, store, &root, policy)?;
Ok(report_text(&report))
}
pub fn run(
config: &Config,
store: &Store,
root: &Path,
policy: ImportPolicy,
out: &mut dyn Write,
) -> Result<MirrorReport> {
let report = mirror(config, store, root, policy)?;
out.write_all(report_text(&report).as_bytes())?;
Ok(report)
}
pub fn mirror(
config: &Config,
store: &Store,
root: &Path,
policy: ImportPolicy,
) -> Result<MirrorReport> {
let artifacts = discover(root)?;
let router = import_policy::resolve(config, policy)?;
let requested = config
.default_config()
.columns
.first()
.cloned()
.unwrap_or_else(|| "todo".to_string());
let landed = router.place(LABEL, &requested).landed;
let mut known: BTreeMap<String, u32> = BTreeMap::new();
for issue in store.list_all()? {
for file in &issue.files {
known.entry(file.clone()).or_insert(issue.number);
}
}
let mut report = MirrorReport::default();
for feature in &artifacts.features {
let parent_number = match known.get(&feature.dir).copied() {
Some(number) => {
report.skipped += 1;
number
}
None => {
let body = format!(
"Mirrored from Spec Kit feature `{dir}`.\n\nTasks source: `{tasks}`.",
dir = feature.dir,
tasks = feature.tasks_path,
);
let issue = create_mirror(
config,
store,
&feature.name,
&body,
&landed,
&feature.dir,
None,
)?;
known.insert(feature.dir.clone(), issue.number);
report.features += 1;
issue.number
}
};
for task in &feature.tasks {
let key = format!("{}#{}", feature.tasks_path, task.id);
if known.contains_key(&key) {
report.skipped += 1;
continue;
}
let title = if task.description.is_empty() {
task.id.clone()
} else {
format!("{}: {}", task.id, task.description)
};
let body = format!(
"Spec Kit task `{id}` from `{path}`.\n\n```text\n{raw}\n```",
id = task.id,
path = feature.tasks_path,
raw = task.raw,
);
let parent = parent_number.to_string();
let issue = create_mirror(config, store, &title, &body, &landed, &key, Some(&parent))?;
known.insert(key, issue.number);
report.tasks += 1;
}
}
for (artifacts, counter) in [
(&artifacts.bugs, &mut report.bugs),
(&artifacts.assessments, &mut report.assessments),
] {
for artifact in artifacts {
if known.contains_key(&artifact.dir) {
report.skipped += 1;
continue;
}
let title = artifact.slug.clone();
let issue = create_mirror(
config,
store,
&title,
&artifact.body,
&landed,
&artifact.dir,
None,
)?;
known.insert(artifact.dir.clone(), issue.number);
*counter += 1;
}
}
Ok(report)
}
fn create_mirror(
config: &Config,
store: &Store,
title: &str,
body: &str,
landed: &str,
source: &str,
parent: Option<&str>,
) -> Result<crate::model::Issue> {
create::create(
config,
store,
title,
Some(body),
Some(Status::new(landed.to_string())),
None, None, Some(LABEL), Some(source), None, None, None, None, None, None, parent, None, None, )
}
pub fn discover(root: &Path) -> Result<SpecKitArtifacts> {
let specify = root.join(".specify");
if !specify.is_dir() {
return Err(TixError::InvalidInput(format!(
"no `.specify/` directory at {} — `tix import --target speckit` needs a Spec Kit \
project root",
root.display()
)));
}
let active = active_feature_dir(root);
let mut feature_dirs: BTreeMap<String, PathBuf> = BTreeMap::new();
for (name, path) in subdirs(&root.join("specs"))? {
let rel = format!("specs/{name}");
let is_active = active.as_deref() == Some(rel.as_str());
if path.join("tasks.md").is_file() || is_active {
feature_dirs.insert(rel, path);
}
}
if let Some(active) = active.as_deref() {
let path = root.join(active);
if path.is_dir() {
feature_dirs.entry(active.to_string()).or_insert(path);
}
}
let mut features = Vec::new();
for (dir, path) in feature_dirs {
let name = path
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| dir.clone());
let tasks_path = format!("{dir}/tasks.md");
let tasks = match std::fs::read_to_string(path.join("tasks.md")) {
Ok(markdown) => parse_tasks(&markdown),
Err(_) => Vec::new(),
};
features.push(Feature {
dir,
name,
tasks_path,
tasks,
});
}
let bugs = collect_artifacts(&specify, "bugs")?;
let assessments = collect_artifacts(&specify, "assessments")?;
Ok(SpecKitArtifacts {
features,
bugs,
assessments,
})
}
fn collect_artifacts(specify: &Path, base: &str) -> Result<Vec<Artifact>> {
let mut out = Vec::new();
for (slug, path) in subdirs(&specify.join(base))? {
out.push(Artifact {
dir: format!(".specify/{base}/{slug}"),
body: read_markdown_dir(&path)?,
slug,
});
}
Ok(out)
}
fn active_feature_dir(root: &Path) -> Option<String> {
let raw = std::fs::read_to_string(root.join(".specify/feature.json")).ok()?;
let value: serde_json::Value = serde_json::from_str(&raw).ok()?;
let dir = value
.get("feature_directory")?
.as_str()?
.trim()
.trim_end_matches('/');
if dir.is_empty() {
None
} else {
Some(dir.to_string())
}
}
fn subdirs(dir: &Path) -> Result<Vec<(String, PathBuf)>> {
let mut out = Vec::new();
if !dir.is_dir() {
return Ok(out);
}
for entry in std::fs::read_dir(dir)? {
let entry = entry?;
if !entry.file_type()?.is_dir() {
continue;
}
let name = entry.file_name().to_string_lossy().to_string();
if name.starts_with('.') {
continue;
}
out.push((name, entry.path()));
}
out.sort_by(|a, b| a.0.cmp(&b.0));
Ok(out)
}
fn read_markdown_dir(dir: &Path) -> Result<String> {
let mut files = Vec::new();
if dir.is_dir() {
for entry in std::fs::read_dir(dir)? {
let path = entry?.path();
if path.extension().and_then(|e| e.to_str()) == Some("md") {
files.push(path);
}
}
}
files.sort();
let mut out = String::new();
for path in files {
let name = path
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_default();
if !out.is_empty() {
out.push('\n');
}
out.push_str(&format!("## {name}\n\n"));
out.push_str(&std::fs::read_to_string(&path)?);
if !out.ends_with('\n') {
out.push('\n');
}
}
Ok(out)
}
#[must_use]
pub fn parse_tasks(markdown: &str) -> Vec<Task> {
let mut tasks = Vec::new();
for line in markdown.lines() {
let trimmed = line.trim();
let (done, rest) = if let Some(rest) = trimmed.strip_prefix("- [ ]") {
(false, rest)
} else if let Some(rest) = trimmed
.strip_prefix("- [x]")
.or_else(|| trimmed.strip_prefix("- [X]"))
{
(true, rest)
} else {
continue;
};
let rest = rest.trim_start();
let mut parts = rest.splitn(2, char::is_whitespace);
let id = parts.next().unwrap_or("");
if !is_task_id(id) {
continue;
}
let mut description = parts.next().unwrap_or("").trim_start();
loop {
let current = description.trim_start();
if let Some(stripped) = current.strip_prefix("[P]") {
description = stripped;
} else if let Some(stripped) = strip_story_tag(current) {
description = stripped;
} else {
description = current;
break;
}
}
tasks.push(Task {
id: id.to_string(),
description: description.trim().to_string(),
raw: trimmed.to_string(),
done,
});
}
tasks
}
fn is_task_id(token: &str) -> bool {
token.len() >= 2 && token.starts_with('T') && token[1..].bytes().all(|b| b.is_ascii_digit())
}
fn strip_story_tag(text: &str) -> Option<&str> {
let text = text.strip_prefix("[US")?;
let end = text.find(']')?;
if !text[..end].bytes().all(|b| b.is_ascii_digit()) || end == 0 {
return None;
}
Some(&text[end + 1..])
}
#[cfg(test)]
mod tests {
#[allow(clippy::wildcard_imports)]
use super::*;
use std::fs;
use tempfile::TempDir;
#[test]
fn parse_tasks_reads_the_checklist_format() {
let md = "\
# Tasks
## Phase 1: Setup
- [ ] T001 Create project structure
- [x] T002 [P] Add dependencies in Cargo.toml
- [ ] T003 [US1] [P] Implement the login form in src/login.rs
- Not a task line
- [ ] no id here
- [X] T004 Done one
";
let tasks = parse_tasks(md);
assert_eq!(tasks.len(), 4);
assert_eq!(tasks[0].id, "T001");
assert_eq!(tasks[0].description, "Create project structure");
assert!(!tasks[0].done);
assert_eq!(tasks[1].id, "T002");
assert_eq!(tasks[1].description, "Add dependencies in Cargo.toml");
assert!(tasks[1].done);
assert_eq!(tasks[2].id, "T003");
assert_eq!(
tasks[2].description,
"Implement the login form in src/login.rs"
);
assert_eq!(tasks[3].id, "T004");
assert!(tasks[3].done);
}
#[test]
fn discover_reads_features_bugs_and_assessments() {
let tmp = TempDir::new().unwrap();
let root = tmp.path();
fs::create_dir_all(root.join(".specify/memory")).unwrap();
fs::write(
root.join(".specify/feature.json"),
r#"{ "feature_directory": "specs/004-empty" }"#,
)
.unwrap();
fs::create_dir_all(root.join("specs/003-user-auth")).unwrap();
fs::write(
root.join("specs/003-user-auth/tasks.md"),
"- [ ] T001 Do the thing\n- [ ] T002 [P] Do another\n",
)
.unwrap();
fs::create_dir_all(root.join("specs/004-empty")).unwrap();
fs::write(root.join("specs/004-empty/spec.md"), "# spec\n").unwrap();
fs::create_dir_all(root.join("specs/notes")).unwrap();
fs::create_dir_all(root.join(".specify/bugs/login-crash")).unwrap();
fs::write(
root.join(".specify/bugs/login-crash/assess.md"),
"## Assessment\n\nboom\n",
)
.unwrap();
fs::create_dir_all(root.join(".specify/assessments/offline-mode")).unwrap();
fs::write(
root.join(".specify/assessments/offline-mode/decide.md"),
"go\n",
)
.unwrap();
let artifacts = discover(root).unwrap();
let names: Vec<&str> = artifacts.features.iter().map(|f| f.dir.as_str()).collect();
assert!(names.contains(&"specs/003-user-auth"));
assert!(names.contains(&"specs/004-empty"));
assert!(!names.contains(&"specs/notes"));
let feature = artifacts
.features
.iter()
.find(|f| f.dir == "specs/003-user-auth")
.unwrap();
assert_eq!(feature.tasks.len(), 2);
assert_eq!(feature.tasks_path, "specs/003-user-auth/tasks.md");
assert_eq!(artifacts.bugs.len(), 1);
assert_eq!(artifacts.bugs[0].dir, ".specify/bugs/login-crash");
assert!(artifacts.bugs[0].body.contains("## assess.md"));
assert!(artifacts.bugs[0].body.contains("boom"));
assert_eq!(artifacts.assessments.len(), 1);
assert_eq!(artifacts.assessments[0].slug, "offline-mode");
}
#[test]
fn discover_without_specify_is_an_invalid_input_error() {
let tmp = TempDir::new().unwrap();
let err = discover(tmp.path()).unwrap_err();
assert!(matches!(err, TixError::InvalidInput(_)), "{err}");
assert!(err.to_string().contains(".specify/"));
}
}