use serde_json::Value;
use speccoder_core::snapshot::{ConstitutionState, Diagnostic, Severity, SpeckitMeta};
use crate::workspace::{ReadError, Workspace};
const INIT_OPTIONS: &str = ".specify/init-options.json";
const INTEGRATION: &str = ".specify/integration.json";
const FEATURE: &str = ".specify/feature.json";
const CONSTITUTION: &str = ".specify/memory/constitution.md";
const TEMPLATE_MARKERS: &[&str] = &["[PROJECT_NAME]", "[PRINCIPLE_1_NAME]"];
pub fn read(ws: &Workspace, diags: &mut Vec<Diagnostic>) -> SpeckitMeta {
let mut meta = SpeckitMeta::default();
if let Some(init) = read_json(ws, INIT_OPTIONS, true, diags) {
meta.version = string(&init, "speckit_version");
meta.integration = string(&init, "integration").or_else(|| string(&init, "ai"));
meta.script = string(&init, "script");
meta.feature_numbering =
string(&init, "feature_numbering").or_else(|| string(&init, "branch_numbering"));
}
if let Some(integration) = read_json(ws, INTEGRATION, true, diags) {
if meta.integration.is_none() {
meta.integration = string(&integration, "default_integration")
.or_else(|| string(&integration, "integration"));
}
if meta.version.is_none() {
meta.version = string(&integration, "version");
}
}
if let Some(feature) = read_json(ws, FEATURE, false, diags) {
meta.current_feature = string(&feature, "feature_directory");
}
meta
}
pub fn constitution(ws: &Workspace, diags: &mut Vec<Diagnostic>) -> ConstitutionState {
match ws.read_text(CONSTITUTION) {
Ok(text) if TEMPLATE_MARKERS.iter().any(|m| text.contains(m)) => {
diags.push(
Diagnostic::new(
Severity::Warning,
"discover.constitution-unfilled",
"the constitution is still the Spec Kit template",
)
.at(CONSTITUTION)
.hint("speccoder spec constitution fills it in (/speckit-constitution in your editor)"),
);
ConstitutionState::Unfilled
}
Ok(_) => ConstitutionState::Present,
Err(ReadError::Missing) => {
diags.push(
Diagnostic::new(
Severity::Warning,
"discover.constitution-missing",
"no project constitution",
)
.at(CONSTITUTION)
.hint("speccoder spec constitution (/speckit-constitution in your editor)"),
);
ConstitutionState::Absent
}
Err(e) => {
diags.push(read_error(CONSTITUTION, &e));
ConstitutionState::Absent
}
}
}
fn read_json(
ws: &Workspace,
rel: &str,
report_missing: bool,
diags: &mut Vec<Diagnostic>,
) -> Option<Value> {
match ws.read_text(rel) {
Ok(text) => match serde_json::from_str::<Value>(&text) {
Ok(v) if v.is_object() => Some(v),
Ok(_) | Err(_) => {
diags.push(
Diagnostic::new(
Severity::Warning,
"discover.metadata-invalid",
format!("{rel} is not a JSON object"),
)
.at(rel),
);
None
}
},
Err(ReadError::Missing) => {
if report_missing {
diags.push(
Diagnostic::new(
Severity::Warning,
"discover.metadata-missing",
format!("{rel} not found"),
)
.at(rel)
.hint("re-run `specify init --here` to restore Spec Kit metadata"),
);
}
None
}
Err(e) => {
diags.push(read_error(rel, &e));
None
}
}
}
fn string(v: &Value, key: &str) -> Option<String> {
v.get(key).and_then(Value::as_str).map(str::to_owned)
}
pub fn read_error(rel: &str, e: &ReadError) -> Diagnostic {
match e {
ReadError::Escape => Diagnostic::new(
Severity::Error,
"discover.escape",
format!("{rel} resolves outside the project root; not read"),
)
.at(rel),
ReadError::TooLarge(n) => Diagnostic::new(
Severity::Error,
"discover.too-large",
format!(
"{rel} is {n} bytes, above the {} byte limit; not parsed",
crate::workspace::MAX_FILE_BYTES
),
)
.at(rel),
ReadError::NotUtf8 => Diagnostic::new(
Severity::Error,
"discover.not-utf8",
format!("{rel} is not valid UTF-8"),
)
.at(rel),
ReadError::Missing => Diagnostic::new(
Severity::Warning,
"discover.missing",
format!("{rel} not found"),
)
.at(rel),
ReadError::Io(msg) => Diagnostic::new(
Severity::Error,
"discover.io",
format!("{rel} could not be read: {msg}"),
)
.at(rel),
}
}