use anyhow::{anyhow, Context, Result};
use fs2::FileExt;
use serde_json::{json, Map, Value};
use sha2::{Digest, Sha256};
use std::fs;
use std::path::{Path, PathBuf};
use crate::model::Project;
pub const FORMAT_TAG: &str = "req-v3";
pub const FORMAT_TAG_DIR: &str = "req-v1-dir";
pub const SCHEMA_REV: u64 = 1;
fn guard_schema_rev(map: &Map<String, Value>, path: &Path) -> Result<()> {
let on_disk = map.get("_schema_rev").and_then(Value::as_u64).unwrap_or(0);
if on_disk > SCHEMA_REV {
return Err(anyhow!(
"{} was written by a newer `req` (schema rev {}, this binary speaks \
rev {}). Upgrade the binary — this version would drop fields it does \
not understand.",
path.display(),
on_disk,
SCHEMA_REV
));
}
Ok(())
}
pub enum Layout {
Single,
Directory,
}
pub fn detect_layout(path: &Path) -> Layout {
if path.is_dir() {
Layout::Directory
} else {
Layout::Single
}
}
pub const WARNING_HEADLINE: &str = "DO NOT EDIT THIS FILE BY HAND. Managed by the `req` CLI.";
pub fn instructions_block() -> Vec<String> {
vec![
"DO NOT EDIT THIS FILE BY HAND.".into(),
"".into(),
"This file is the source of truth for a managed requirements project. It is".into(),
"git-diffable so humans can review changes in pull requests, but every".into(),
"mutation must go through the `req` CLI so that best-practice validation".into(),
"runs (atomic statements, modal verbs, acceptance criteria, no weasel words,".into(),
"no broken links, etc.).".into(),
"".into(),
"Integrity: the `_integrity` field is a SHA-256 of the canonical payload.".into(),
"If you edit this file directly the hash will no longer match and the CLI".into(),
"will refuse to read it. To recover after an intentional manual edit:".into(),
" req repair --confirm-direct-edit".into(),
"".into(),
"Common commands:".into(),
" req init -n <name> create a new project".into(),
" req add interactive add (recommended)".into(),
" req list [--status ...] [--kind ...] table of requirements".into(),
" req show REQ-0001 full detail for one".into(),
" req update REQ-0001 --status approved --reason \"team review\"".into(),
" req link REQ-0002 REQ-0001 -k parent hierarchy / traceability".into(),
" req validate run rules across the project".into(),
" req export -f markdown -o reqs.md publish".into(),
" req tui interactive terminal browser".into(),
" req help <section> structured help; try: overview,".into(),
" concepts, best-practice, workflow,".into(),
" file-format, agents, export".into(),
"".into(),
"Agents: never edit this file. Drive `req` with subcommands, or use `req mcp`".into(),
"(when available) to manage requirements through the JSON-RPC interface.".into(),
]
}
pub fn resolve_path(explicit: &Option<PathBuf>) -> PathBuf {
if let Some(p) = explicit {
return p.clone();
}
PathBuf::from("project.req")
}
pub fn save(path: &Path, project: &Project) -> Result<()> {
match detect_layout(path) {
Layout::Directory => return save_directory(path, project),
Layout::Single => {}
}
let mut payload_map = match serde_json::to_value(project).context("serialize project")? {
Value::Object(m) => m,
_ => return Err(anyhow!("project did not serialize to a JSON object")),
};
payload_map.insert("_schema_rev".into(), Value::Number(SCHEMA_REV.into()));
let payload = Value::Object(payload_map);
let mut root = Map::new();
root.insert("_warning".into(), Value::String(WARNING_HEADLINE.into()));
root.insert(
"_instructions".into(),
Value::Array(
instructions_block()
.into_iter()
.map(Value::String)
.collect(),
),
);
root.insert("_format".into(), Value::String(FORMAT_TAG.into()));
root.insert("_integrity".into(), Value::String(integrity_hash(&payload)));
if let Value::Object(map) = payload {
for (k, v) in map {
root.insert(k, v);
}
} else {
return Err(anyhow!("project did not serialize to a JSON object"));
}
if let Some(parent) = path.parent() {
if !parent.as_os_str().is_empty() {
fs::create_dir_all(parent).ok();
}
}
let tmp = path.with_extension("req.tmp");
let s = serde_json::to_string_pretty(&Value::Object(root))?;
fs::write(&tmp, s)?;
fs::rename(&tmp, path).with_context(|| format!("rename to {}", path.display()))?;
Ok(())
}
pub fn load(path: &Path) -> Result<Project> {
load_with_options(path, false)
}
pub fn load_with_options(path: &Path, force: bool) -> Result<Project> {
if path.is_dir() {
return load_directory(path, force);
}
let s = fs::read_to_string(path)
.with_context(|| format!("open {} (run `req init` first?)", path.display()))?;
let mut root: Map<String, Value> = serde_json::from_str(&s)
.with_context(|| format!("{} is not valid JSON", path.display()))?;
match root.get("_format").and_then(|v| v.as_str()) {
Some(FORMAT_TAG) => {}
Some(other) => {
let is_older = other < FORMAT_TAG;
if is_older
&& std::env::var("REQ_NO_AUTO_MIGRATE").is_err()
&& has_migration_path(other, FORMAT_TAG)
{
eprintln!(
"req: auto-migrating {} from {} to {} (set REQ_NO_AUTO_MIGRATE=1 to opt out)",
path.display(),
other,
FORMAT_TAG
);
auto_migrate_in_place(path, &s)?;
let s2 = fs::read_to_string(path)
.with_context(|| format!("re-open {} after auto-migrate", path.display()))?;
root = serde_json::from_str(&s2).with_context(|| {
format!("{} not valid JSON after auto-migrate", path.display())
})?;
} else {
let hint = if is_older {
"run `req migrate` to upgrade the file in place (a backup is written)"
} else {
"upgrade the `req` binary — this file uses a newer format than this binary understands"
};
return Err(anyhow!(
"unsupported _format: {} (this binary speaks {}). {}",
other,
FORMAT_TAG,
hint
));
}
}
None => return Err(anyhow!("not a .req file: missing _format field")),
}
guard_schema_rev(&root, path)?;
let stored_hash = root
.remove("_integrity")
.and_then(|v| v.as_str().map(|s| s.to_string()))
.ok_or_else(|| anyhow!("missing _integrity field"))?;
root.remove("_warning");
root.remove("_instructions");
root.remove("_format");
let mut payload = Value::Object(root);
let actual = integrity_hash(&payload);
if !force && actual != stored_hash {
return Err(anyhow!(
"integrity check failed for {} — file appears to have been edited \
directly.\n\nIf the changes are intentional, review them with `git diff` \
and then run:\n req repair --confirm-direct-edit\n\nOtherwise restore \
from version control.",
path.display()
));
}
if let Value::Object(m) = &mut payload {
m.remove("_schema_rev");
}
let project: Project = serde_json::from_value(payload).context("deserialize project")?;
Ok(project)
}
pub fn load_resolved(p: &Option<PathBuf>) -> Result<(PathBuf, Project)> {
let path = resolve_path(p);
let project = load(&path)?;
Ok((path, project))
}
pub struct LockGuard {
file: Option<std::fs::File>,
#[allow(dead_code)]
path: PathBuf,
}
impl Drop for LockGuard {
fn drop(&mut self) {
if let Some(f) = self.file.take() {
let _ = f.unlock();
}
}
}
const LOCK_TIMEOUT_SECS: u64 = 30;
pub fn acquire_lock(project_path: &Path) -> Result<LockGuard> {
let lock_path = lock_sidecar(project_path);
if let Some(parent) = lock_path.parent() {
if !parent.as_os_str().is_empty() {
fs::create_dir_all(parent).ok();
}
}
let file = std::fs::OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false)
.open(&lock_path)
.with_context(|| format!("open lock file {}", lock_path.display()))?;
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(LOCK_TIMEOUT_SECS);
loop {
match file.try_lock_exclusive() {
Ok(()) => {
return Ok(LockGuard {
file: Some(file),
path: lock_path,
})
}
Err(_) if std::time::Instant::now() >= deadline => {
return Err(anyhow!(
"could not acquire {} within {}s — another req process is mutating the project",
lock_path.display(),
LOCK_TIMEOUT_SECS,
));
}
Err(_) => std::thread::sleep(std::time::Duration::from_millis(50)),
}
}
}
fn lock_sidecar(project_path: &Path) -> PathBuf {
let mut p = project_path.to_path_buf();
let new_name = match p.file_name().and_then(|s| s.to_str()) {
Some(n) => format!(".{}.lock", n),
None => ".project.req.lock".into(),
};
p.set_file_name(new_name);
p
}
pub fn load_for_mutation(p: &Option<PathBuf>) -> Result<(PathBuf, Project, LockGuard)> {
let path = resolve_path(p);
let guard = acquire_lock(&path)?;
let project = load(&path)?;
Ok((path, project, guard))
}
pub fn save_directory(root: &Path, project: &Project) -> Result<()> {
fs::create_dir_all(root).with_context(|| format!("create {}", root.display()))?;
let reqs_dir = root.join("requirements");
fs::create_dir_all(&reqs_dir).with_context(|| format!("create {}", reqs_dir.display()))?;
for (id, req) in &project.requirements {
let p = reqs_dir.join(format!("{}.req", id));
let body = serde_json::to_string_pretty(req)?;
let tmp = p.with_extension("req.tmp");
fs::write(&tmp, body)?;
fs::rename(&tmp, &p)?;
}
if let Ok(entries) = fs::read_dir(&reqs_dir) {
for e in entries.flatten() {
let name = e.file_name();
let name_s = name.to_string_lossy();
if let Some(stem) = name_s.strip_suffix(".req") {
if !project.requirements.contains_key(stem) {
let _ = fs::remove_file(e.path());
}
}
}
}
let hash = directory_integrity(project)?;
let mut root_obj = serde_json::Map::new();
root_obj.insert("_warning".into(), Value::String(WARNING_HEADLINE.into()));
root_obj.insert(
"_instructions".into(),
Value::Array(
instructions_block()
.into_iter()
.map(Value::String)
.collect(),
),
);
root_obj.insert("_format".into(), Value::String(FORMAT_TAG_DIR.into()));
root_obj.insert("_schema_rev".into(), Value::Number(SCHEMA_REV.into()));
root_obj.insert("_integrity".into(), Value::String(hash));
root_obj.insert("name".into(), Value::String(project.name.clone()));
root_obj.insert("created".into(), serde_json::to_value(project.created)?);
root_obj.insert("updated".into(), serde_json::to_value(project.updated)?);
root_obj.insert("next_id".into(), Value::Number(project.next_id.into()));
root_obj.insert(
"requirement_ids".into(),
Value::Array(
project
.requirements
.keys()
.map(|k| Value::String(k.clone()))
.collect(),
),
);
insert_safety_fields(&mut root_obj, project)?;
let index_path = root.join("index.req");
let body = serde_json::to_string_pretty(&Value::Object(root_obj))?;
let tmp = index_path.with_extension("req.tmp");
fs::write(&tmp, body)?;
fs::rename(&tmp, &index_path)?;
Ok(())
}
pub fn load_directory(root: &Path, force: bool) -> Result<Project> {
let index_path = root.join("index.req");
let s = fs::read_to_string(&index_path)
.with_context(|| format!("read {}", index_path.display()))?;
let mut root_obj: serde_json::Map<String, Value> = serde_json::from_str(&s)
.with_context(|| format!("{} is not valid JSON", index_path.display()))?;
match root_obj.get("_format").and_then(|v| v.as_str()) {
Some(FORMAT_TAG_DIR) => {}
Some(other) => return Err(anyhow!("unsupported _format in directory index: {}", other)),
None => return Err(anyhow!("not a directory-layout project: missing _format")),
}
guard_schema_rev(&root_obj, &index_path)?;
let stored_hash = root_obj
.remove("_integrity")
.and_then(|v| v.as_str().map(|s| s.to_string()))
.ok_or_else(|| anyhow!("missing _integrity in directory index"))?;
let reqs_dir = root.join("requirements");
let mut requirements = std::collections::BTreeMap::new();
if let Ok(entries) = fs::read_dir(&reqs_dir) {
for e in entries.flatten() {
let p = e.path();
if p.extension().and_then(|s| s.to_str()) != Some("req") {
continue;
}
let body = fs::read_to_string(&p)?;
let req: crate::model::Requirement =
serde_json::from_str(&body).with_context(|| format!("parse {}", p.display()))?;
requirements.insert(req.id.clone(), req);
}
}
let project = Project {
name: root_obj
.get("name")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
created: serde_json::from_value(root_obj.remove("created").unwrap_or(Value::Null))?,
updated: serde_json::from_value(root_obj.remove("updated").unwrap_or(Value::Null))?,
next_id: root_obj
.get("next_id")
.and_then(|v| v.as_u64())
.unwrap_or(1) as u32,
requirements,
hazards: root_obj
.remove("hazards")
.and_then(|v| serde_json::from_value(v).ok())
.unwrap_or_default(),
safety_functions: root_obj
.remove("safety_functions")
.and_then(|v| serde_json::from_value(v).ok())
.unwrap_or_default(),
safety_requirements: root_obj
.remove("safety_requirements")
.and_then(|v| serde_json::from_value(v).ok())
.unwrap_or_default(),
next_haz_id: root_obj
.get("next_haz_id")
.and_then(|v| v.as_u64())
.unwrap_or(1) as u32,
next_sf_id: root_obj
.get("next_sf_id")
.and_then(|v| v.as_u64())
.unwrap_or(1) as u32,
next_sr_id: root_obj
.get("next_sr_id")
.and_then(|v| v.as_u64())
.unwrap_or(1) as u32,
purpose: root_obj
.remove("_purpose")
.and_then(|v| serde_json::from_value(v).ok()),
config: root_obj
.remove("_config")
.and_then(|v| serde_json::from_value(v).ok()),
extra: Default::default(),
};
if !force {
let actual = directory_integrity(&project)?;
if actual != stored_hash {
return Err(anyhow!(
"integrity check failed for directory-layout project at {} — files appear edited.\n\
If intentional, run: req repair --confirm-direct-edit",
root.display()
));
}
}
Ok(project)
}
fn directory_integrity(project: &Project) -> Result<String> {
let mut hasher = Sha256::new();
let mut header = serde_json::Map::new();
header.insert("name".into(), Value::String(project.name.clone()));
header.insert("next_id".into(), Value::Number(project.next_id.into()));
header.insert("created".into(), serde_json::to_value(project.created)?);
header.insert("updated".into(), serde_json::to_value(project.updated)?);
insert_safety_fields(&mut header, project)?;
hasher.update(canonical_json(&Value::Object(header)).as_bytes());
for req in project.requirements.values() {
let v = serde_json::to_value(req)?;
hasher.update(canonical_json(&v).as_bytes());
}
Ok(format!("sha256:{}", hex::encode(hasher.finalize())))
}
fn insert_safety_fields(map: &mut Map<String, Value>, project: &Project) -> Result<()> {
if !project.hazards.is_empty() {
map.insert("hazards".into(), serde_json::to_value(&project.hazards)?);
map.insert(
"next_haz_id".into(),
Value::Number(project.next_haz_id.into()),
);
}
if !project.safety_functions.is_empty() {
map.insert(
"safety_functions".into(),
serde_json::to_value(&project.safety_functions)?,
);
map.insert(
"next_sf_id".into(),
Value::Number(project.next_sf_id.into()),
);
}
if !project.safety_requirements.is_empty() {
map.insert(
"safety_requirements".into(),
serde_json::to_value(&project.safety_requirements)?,
);
map.insert(
"next_sr_id".into(),
Value::Number(project.next_sr_id.into()),
);
}
Ok(())
}
fn has_migration_path(from: &str, to: &str) -> bool {
use crate::migrations;
let steps = migrations::registered_steps();
let mut current = from.to_string();
let mut seen = std::collections::HashSet::new();
while current != to {
if !seen.insert(current.clone()) {
return false; }
match steps.iter().find(|s| s.from == current) {
Some(step) => current = step.to.to_string(),
None => return false,
}
}
true
}
fn auto_migrate_in_place(path: &Path, raw_before: &str) -> Result<()> {
use crate::migrations;
let mut root: Map<String, Value> = serde_json::from_str(raw_before)?;
let detected: String = root
.get("_format")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("not a .req file: missing _format"))?
.to_string();
let backup = path.with_extension(format!("req.bak-{}", detected));
fs::copy(path, &backup).with_context(|| format!("write backup {}", backup.display()))?;
let stored_hash = root
.remove("_integrity")
.and_then(|v| v.as_str().map(|s| s.to_string()))
.ok_or_else(|| anyhow!("missing _integrity field"))?;
root.remove("_warning");
root.remove("_instructions");
root.remove("_format");
let payload_before = Value::Object(root.clone());
let computed = integrity_hash(&payload_before);
if computed != stored_hash {
return Err(anyhow!(
"integrity check failed for {} before auto-migrate — run \
`req repair --confirm-direct-edit` first, then re-run the \
original command. Backup at {}.",
path.display(),
backup.display()
));
}
let (migrated, ended_at) = migrations::walk_chain(root, &detected, FORMAT_TAG)?;
let final_payload = Value::Object(migrated);
let new_hash = integrity_hash(&final_payload);
let mut final_root: Map<String, Value> = match final_payload {
Value::Object(m) => m,
_ => unreachable!("walk_chain returns Object root"),
};
final_root.insert("_format".into(), Value::String(ended_at.clone()));
final_root.insert("_integrity".into(), Value::String(new_hash));
let serialised = serde_json::to_string_pretty(&Value::Object(final_root))?;
fs::write(path, serialised).with_context(|| format!("write {}", path.display()))?;
eprintln!(
"req: auto-migrated → {} (backup at {})",
ended_at,
backup.display()
);
Ok(())
}
pub fn integrity_hash(payload: &Value) -> String {
let canonical = canonical_json(payload);
let mut hasher = Sha256::new();
hasher.update(canonical.as_bytes());
format!("sha256:{}", hex::encode(hasher.finalize()))
}
fn canonical_json(v: &Value) -> String {
match v {
Value::Object(map) => {
let mut keys: Vec<&String> = map.keys().collect();
keys.sort();
let mut out = String::from("{");
for (i, k) in keys.iter().enumerate() {
if i > 0 {
out.push(',');
}
out.push_str(&serde_json::to_string(k).unwrap());
out.push(':');
out.push_str(&canonical_json(&map[*k]));
}
out.push('}');
out
}
Value::Array(arr) => {
let mut out = String::from("[");
for (i, item) in arr.iter().enumerate() {
if i > 0 {
out.push(',');
}
out.push_str(&canonical_json(item));
}
out.push(']');
out
}
_ => serde_json::to_string(v).unwrap(),
}
}
#[allow(dead_code)]
pub fn _force_json() -> Value {
json!(null)
}