Skip to main content

speccoder_openspec/
lib.rs

1//! OpenSpec adapter for speccoder.
2//!
3//! Finds an OpenSpec project and its active changes, parses a change's
4//! `tasks.md` (as OpenSpec counts tasks) and spec deltas (scenarios included),
5//! and checks before any model call what `openspec archive` would refuse
6//! later. Read-only except [`write::tick`] and [`write::append_convergence`];
7//! it never runs OpenSpec's CLI ([`crosscheck`] only reads its JSON).
8
9pub mod crosscheck;
10pub mod delta;
11pub mod discover;
12mod hash;
13pub mod preflight;
14pub mod tasks;
15pub mod write;
16
17use std::fs;
18use std::path::{Path, PathBuf};
19
20use speccoder_core::snapshot::{
21    Artifacts, ConstitutionState, Diagnostic, FeatureRef, FeatureSnapshot, ProjectSnapshot,
22    Selection, SelectionReason, Severity, SpeckitMeta,
23};
24use speccoder_core::spec::{DeltaRequirement, FormatKind, OpenSpecMeta};
25
26pub use hash::sha256_hex;
27
28/// No OpenSpec directory was found from the start path upward.
29#[derive(Debug, Clone, PartialEq, Eq)]
30pub struct NotAProject;
31
32/// A change's files, read.
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub struct Change {
35    /// Its id (directory name).
36    pub id: String,
37    /// `openspec/changes/<id>`, relative to the root.
38    pub dir: String,
39    /// The change's schema (its `.openspec.yaml`, else the project's).
40    pub schema: String,
41    /// `proposal.md`, if present.
42    pub proposal: Option<String>,
43    /// `design.md`, if present.
44    pub design: Option<String>,
45    /// Its spec deltas, by capability.
46    pub deltas: Vec<delta::Delta>,
47}
48
49/// The change directory of `id`, relative to the root.
50#[must_use]
51pub fn change_dir(id: &str) -> String {
52    format!("{}/changes/{id}", discover::DIR)
53}
54
55/// Reads change `id` of the project at `root`, with the deltas' problems.
56#[must_use]
57pub fn load_change(root: &Path, id: &str, project_schema: &str) -> (Change, Vec<Diagnostic>) {
58    let dir = change_dir(id);
59    let abs = root.join(&dir);
60    let read = |name: &str| fs::read_to_string(abs.join(name)).ok();
61    let mut diags = Vec::new();
62    let deltas = discover::spec_files(&abs.join("specs"))
63        .into_iter()
64        .filter_map(|(cap, path)| {
65            let text = fs::read_to_string(&path).ok()?;
66            let rel = format!("{dir}/specs/{cap}/spec.md");
67            let (d, mut found) = delta::parse(&cap, &rel, &text);
68            diags.append(&mut found);
69            Some(d)
70        })
71        .collect();
72    (
73        Change {
74            id: id.to_owned(),
75            schema: discover::change_schema(&abs).unwrap_or_else(|| project_schema.to_owned()),
76            proposal: read("proposal.md"),
77            design: read("design.md"),
78            dir,
79            deltas,
80        },
81        diags,
82    )
83}
84
85/// Requirement names per capability of the main specs of `root`.
86#[must_use]
87pub fn main_specs(
88    root: &Path,
89) -> std::collections::BTreeMap<String, std::collections::BTreeSet<String>> {
90    let files: Vec<(String, String)> =
91        discover::spec_files(&root.join(discover::DIR).join("specs"))
92            .into_iter()
93            .filter_map(|(cap, p)| fs::read_to_string(p).ok().map(|t| (cap, t)))
94            .collect();
95    delta::main_requirements(&files)
96}
97
98/// Inspects the OpenSpec project containing `start`, selecting `change` if
99/// given, else the only active change. Preflight problems of the selected
100/// change are among the diagnostics.
101pub fn inspect(start: &Path, change: Option<&str>) -> Result<ProjectSnapshot, NotAProject> {
102    let root: PathBuf = discover::find_root(start).ok_or(NotAProject)?;
103    let mut diags = Vec::new();
104    let config = discover::config(&root, &mut diags);
105    preflight::project(&root, &config, &mut diags);
106    let ids = discover::changes(&root);
107    let features: Vec<FeatureRef> = ids
108        .iter()
109        .map(|id| FeatureRef {
110            dir: change_dir(id),
111            name: id.clone(),
112            prefix: String::new(),
113        })
114        .collect();
115
116    let selection = select(&ids, change, &mut diags);
117    let mut requirements = Vec::new();
118    let feature = selection.feature.as_deref().map(|id| {
119        let (c, mut found) = load_change(&root, id, &config.schema);
120        diags.append(&mut found);
121        let tasks_rel = format!("{}/tasks.md", c.dir);
122        let tasks = match fs::read_to_string(root.join(&tasks_rel)) {
123            Ok(text) => {
124                let (parsed, mut found) = tasks::parse(&tasks_rel, &text);
125                diags.append(&mut found);
126                Some(parsed)
127            }
128            Err(_) => {
129                diags.push(
130                    Diagnostic::new(Severity::Error, "no_tasks", "the change has no tasks.md")
131                        .at(&tasks_rel)
132                        .hint("create it with the change's propose skill"),
133                );
134                None
135            }
136        };
137        let schedule = tasks.as_ref().map(|t| {
138            let (s, mut found) = speccoder_core::graph::schedule(t);
139            diags.append(&mut found);
140            s
141        });
142        preflight::change(&c, &main_specs(&root), &mut diags);
143        requirements = requirements_of(&c);
144        let abs = root.join(&c.dir);
145        FeatureSnapshot {
146            feature: FeatureRef {
147                dir: c.dir.clone(),
148                name: c.id.clone(),
149                prefix: String::new(),
150            },
151            artifacts: Artifacts {
152                spec: !c.deltas.is_empty(),
153                plan: abs.join("design.md").is_file(),
154                tasks: tasks.is_some(),
155                research: abs.join("proposal.md").is_file(),
156                ..Artifacts::default()
157            },
158            tasks,
159            schedule,
160        }
161    });
162
163    Ok(ProjectSnapshot {
164        root: root.display().to_string(),
165        speckit: SpeckitMeta::default(),
166        constitution: if config.context.is_some() {
167            ConstitutionState::Present
168        } else {
169            ConstitutionState::Absent
170        },
171        features,
172        selection,
173        feature,
174        diagnostics: diags,
175        format: FormatKind::Openspec,
176        openspec: Some(OpenSpecMeta {
177            schema: config.schema,
178            context: config.context,
179            unit_kind: "change".to_owned(),
180            requirements,
181        }),
182    })
183}
184
185fn select(ids: &[String], wanted: Option<&str>, diags: &mut Vec<Diagnostic>) -> Selection {
186    let none = Selection {
187        reason: SelectionReason::None,
188        feature: None,
189    };
190    match wanted {
191        Some(w) => {
192            if ids.iter().any(|i| i == w) {
193                Selection {
194                    reason: SelectionReason::Argument,
195                    feature: Some(w.to_owned()),
196                }
197            } else {
198                diags.push(
199                    Diagnostic::new(
200                        Severity::Error,
201                        "select.not-found",
202                        format!("no active change `{w}`"),
203                    )
204                    .hint(if ids.is_empty() {
205                        "there are no active changes under openspec/changes/".to_owned()
206                    } else {
207                        format!("active changes: {}", ids.join(", "))
208                    }),
209                );
210                none
211            }
212        }
213        None => match ids {
214            [only] => Selection {
215                reason: SelectionReason::OnlyFeature,
216                feature: Some(only.clone()),
217            },
218            [] => {
219                diags.push(
220                    Diagnostic::new(
221                        Severity::Warning,
222                        "select.no-features",
223                        "no active changes under openspec/changes/",
224                    )
225                    .hint("propose one with `speccoder spec propose`"),
226                );
227                none
228            }
229            many => {
230                diags.push(
231                    Diagnostic::new(
232                        Severity::Error,
233                        "select.ambiguous",
234                        format!("{} active changes: {}", many.len(), many.join(", ")),
235                    )
236                    .hint("choose one with --change <id>"),
237                );
238                none
239            }
240        },
241    }
242}
243
244/// A change's delta requirements, renames as their new name.
245fn requirements_of(c: &Change) -> Vec<DeltaRequirement> {
246    let mut out = Vec::new();
247    for d in &c.deltas {
248        for r in &d.requirements {
249            out.push(DeltaRequirement {
250                capability: d.capability.clone(),
251                operation: r.operation.name().to_owned(),
252                name: r.name.clone(),
253                scenarios: r.scenarios.iter().map(|s| s.name.clone()).collect(),
254                path: d.path.clone(),
255                line: r.line,
256            });
257        }
258        for n in &d.renames {
259            out.push(DeltaRequirement {
260                capability: d.capability.clone(),
261                operation: "renamed".to_owned(),
262                name: n.to.clone(),
263                scenarios: Vec::new(),
264                path: d.path.clone(),
265                line: n.line,
266            });
267        }
268    }
269    out
270}