Skip to main content

speccoder_speckit/
lib.rs

1//! GitHub Spec Kit adapter for speccoder.
2//!
3//! Discovers a Spec Kit project's artifacts and parses `tasks.md` into the
4//! snapshot types of `speccoder-core`. Strictly read-only: it never writes,
5//! never runs project scripts, and never reads outside the project root.
6
7pub mod checklists;
8pub mod features;
9mod hash;
10mod metadata;
11pub mod stories;
12pub mod tasks;
13pub mod workspace;
14
15use std::path::Path;
16
17pub use hash::sha256_hex;
18
19use speccoder_core::snapshot::{
20    Artifacts, Diagnostic, FeatureRef, FeatureSnapshot, ProjectSnapshot, Severity,
21};
22
23use crate::workspace::{ReadError, Workspace};
24
25/// No `.specify/` directory was found from the start path upward.
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct NotAProject;
28
29/// Inspects the Spec Kit project containing `start`, selecting `feature`
30/// (full directory name or numeric prefix) if given.
31pub fn inspect(start: &Path, feature: Option<&str>) -> Result<ProjectSnapshot, NotAProject> {
32    inspect_with(start, feature, None)
33}
34
35/// [`inspect`] with the value of `SPECIFY_FEATURE_DIRECTORY`, which the caller
36/// reads, so this crate never consults the process environment itself.
37pub fn inspect_with(
38    start: &Path,
39    feature: Option<&str>,
40    feature_directory_env: Option<&str>,
41) -> Result<ProjectSnapshot, NotAProject> {
42    let ws = workspace::find_root(start).ok_or(NotAProject)?;
43    let mut diags = Vec::new();
44
45    let speckit = metadata::read(&ws, &mut diags);
46    let constitution = metadata::constitution(&ws, &mut diags);
47    let features = list_features(&ws, &mut diags);
48    let selection = features::select(
49        &features,
50        feature,
51        feature_directory_env,
52        speckit.current_feature.as_deref(),
53        &mut diags,
54    );
55    let feature = selection
56        .feature
57        .as_deref()
58        .and_then(|name| features.iter().find(|f| f.name == name))
59        .map(|f| feature_snapshot(&ws, f, &mut diags));
60
61    Ok(ProjectSnapshot {
62        root: ws.root().display().to_string(),
63        speckit,
64        constitution,
65        features,
66        selection,
67        feature,
68        diagnostics: diags,
69        format: speccoder_core::spec::FormatKind::Speckit,
70        openspec: None,
71    })
72}
73
74fn list_features(ws: &Workspace, diags: &mut Vec<Diagnostic>) -> Vec<FeatureRef> {
75    match ws.list_dirs(features::SPECS) {
76        Ok(listing) => {
77            for name in &listing.escaping {
78                diags.push(metadata::read_error(
79                    &format!("{}/{name}", features::SPECS),
80                    &ReadError::Escape,
81                ));
82            }
83            features::collect(&listing.inside, diags)
84        }
85        Err(ReadError::Missing) => {
86            diags.push(
87                Diagnostic::new(
88                    Severity::Warning,
89                    "discover.no-specs-dir",
90                    "no specs/ directory",
91                )
92                .hint("speccoder spec specify \"…\" creates the first feature (/speckit-specify in your editor)"),
93            );
94            Vec::new()
95        }
96        Err(e) => {
97            diags.push(metadata::read_error(features::SPECS, &e));
98            Vec::new()
99        }
100    }
101}
102
103fn feature_snapshot(
104    ws: &Workspace,
105    f: &FeatureRef,
106    diags: &mut Vec<Diagnostic>,
107) -> FeatureSnapshot {
108    let p = |name: &str| format!("{}/{name}", f.dir);
109    let tasks_path = p("tasks.md");
110    let artifacts = Artifacts {
111        spec: ws.is_file(&p("spec.md")),
112        plan: ws.is_file(&p("plan.md")),
113        tasks: ws.is_file(&tasks_path),
114        research: ws.is_file(&p("research.md")),
115        data_model: ws.is_file(&p("data-model.md")),
116        contracts: ws.is_dir(&p("contracts")),
117        quickstart: ws.is_file(&p("quickstart.md")),
118        checklists: ws.is_dir(&p("checklists")),
119    };
120    let tasks = match ws.read_text(&tasks_path) {
121        Ok(text) => {
122            let (parsed, mut found) = tasks::parse(&tasks_path, &text);
123            diags.append(&mut found);
124            Some(parsed)
125        }
126        Err(ReadError::Missing) => {
127            diags.push(
128                Diagnostic::new(Severity::Warning, "tasks.missing", "no tasks.md yet")
129                    .at(&tasks_path)
130                    .hint("speccoder spec plan, then speccoder spec tasks (/speckit-plan, /speckit-tasks in your editor)"),
131            );
132            None
133        }
134        Err(e) => {
135            diags.push(metadata::read_error(&tasks_path, &e));
136            None
137        }
138    };
139    let schedule = tasks.as_ref().map(|t| {
140        let (schedule, mut found) = speccoder_core::graph::schedule(t);
141        diags.append(&mut found);
142        schedule
143    });
144    FeatureSnapshot {
145        feature: f.clone(),
146        artifacts,
147        tasks,
148        schedule,
149    }
150}
151
152/// Ticks `task_id` in the `tasks.md` at `tasks_path` — the engine's one
153/// sanctioned write to an artifact.
154///
155/// Re-parses the file, requires the task's line to still hash to
156/// `expected_line_hash` (else the file changed since the attempt began), and
157/// replaces the space inside the task's `[ ]` with `X`: one byte, nothing
158/// else touched. Written to a sibling temporary file and renamed into place.
159pub fn mark_task_complete(
160    tasks_path: &Path,
161    task_id: &str,
162    expected_line_hash: &str,
163) -> Result<(), String> {
164    let text = std::fs::read_to_string(tasks_path)
165        .map_err(|e| format!("{}: {e}", tasks_path.display()))?;
166    let (parsed, _) = tasks::parse("tasks.md", &text);
167    let task = parsed
168        .tasks
169        .iter()
170        .find(|t| t.id == task_id)
171        .ok_or_else(|| format!("{task_id} is not in {}", tasks_path.display()))?;
172    if task.done {
173        return Err(format!("{task_id} is already ticked"));
174    }
175    if task.line_hash != expected_line_hash {
176        return Err(format!(
177            "{task_id}'s line changed since the attempt began; not ticked"
178        ));
179    }
180    let [start, end] = task.byte_range;
181    let item = &text[start..end.min(text.len())];
182    let open = item
183        .find("[ ]")
184        .ok_or_else(|| format!("{task_id} has no `[ ]` checkbox"))?;
185    let at = start + open + 1;
186    let mut bytes = text.into_bytes();
187    bytes[at] = b'X';
188    let tmp = tasks_path.with_extension("md.speccoder-tick");
189    std::fs::write(&tmp, &bytes).map_err(|e| format!("{}: {e}", tmp.display()))?;
190    std::fs::rename(&tmp, tasks_path).map_err(|e| format!("{}: {e}", tasks_path.display()))
191}
192
193/// Appends a `## Phase N: Convergence` section with one open task per item
194/// to the `tasks.md` at `tasks_path` — the engine's other sanctioned write
195/// (Spec Kit's converge append contract).
196///
197/// The file must still hash to `expected_file_hash`; IDs continue after the
198/// highest existing one, the phase number after the highest existing phase.
199/// The old bytes stay an exact prefix and every existing task re-parses
200/// unchanged. No items → the file is not touched. Returns the new IDs.
201pub fn append_convergence(
202    tasks_path: &Path,
203    expected_file_hash: &str,
204    items: &[String],
205) -> Result<Vec<String>, String> {
206    if items.is_empty() {
207        return Ok(Vec::new());
208    }
209    let text = std::fs::read_to_string(tasks_path)
210        .map_err(|e| format!("{}: {e}", tasks_path.display()))?;
211    if hash::sha256_hex(text.as_bytes()) != expected_file_hash {
212        return Err(format!(
213            "{} changed since convergence began; nothing appended",
214            tasks_path.display()
215        ));
216    }
217    let (parsed, _) = tasks::parse("tasks.md", &text);
218    let max_id = parsed
219        .tasks
220        .iter()
221        .filter_map(|t| t.id.strip_prefix('T').and_then(|n| n.parse::<u32>().ok()))
222        .max()
223        .unwrap_or(0);
224    let phase = parsed
225        .phases
226        .iter()
227        .filter_map(|p| {
228            p.title
229                .strip_prefix("Phase ")
230                .and_then(|r| r.split(|c: char| !c.is_ascii_digit()).next())
231                .and_then(|n| n.parse::<u32>().ok())
232        })
233        .max()
234        .unwrap_or(0)
235        + 1;
236    let mut section = String::new();
237    if !text.is_empty() && !text.ends_with('\n') {
238        section.push('\n');
239    }
240    section.push_str(&format!("\n## Phase {phase}: Convergence\n\n"));
241    let mut ids = Vec::new();
242    for (i, item) in items.iter().enumerate() {
243        let id = format!(
244            "T{:03}",
245            max_id + 1 + u32::try_from(i).unwrap_or(u32::MAX - max_id - 1)
246        );
247        // One line each; a model's text never becomes structure.
248        let line = item.split_whitespace().collect::<Vec<_>>().join(" ");
249        let line = line.trim_start_matches(['-', '*', ' ']);
250        let line = line.strip_prefix("[ ]").unwrap_or(line).trim();
251        section.push_str(&format!("- [ ] {id} {line}\n"));
252        ids.push(id);
253    }
254    let new = format!("{text}{section}");
255    // Prefix guard: the old bytes are untouched and every old task parses the same.
256    let (reparsed, _) = tasks::parse("tasks.md", &new);
257    let same = parsed.tasks.iter().all(|old| {
258        reparsed
259            .tasks
260            .iter()
261            .any(|t| t.id == old.id && t.line_hash == old.line_hash && t.done == old.done)
262    });
263    let added = ids
264        .iter()
265        .all(|id| reparsed.tasks.iter().any(|t| &t.id == id && !t.done));
266    if !new.starts_with(&text) || !same || !added {
267        return Err(
268            "the convergence section would change existing tasks; nothing appended".to_owned(),
269        );
270    }
271    let tmp = tasks_path.with_extension("md.speccoder-append");
272    std::fs::write(&tmp, new.as_bytes()).map_err(|e| format!("{}: {e}", tmp.display()))?;
273    std::fs::rename(&tmp, tasks_path).map_err(|e| format!("{}: {e}", tasks_path.display()))?;
274    Ok(ids)
275}