use std::collections::BTreeMap;
use std::path::Path;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::error::{Error, Result};
use crate::{LOCK_VERSION, LOCKFILE_PATH};
pub const HEADER: &str = "# This file is generated by harness-lock. Do not edit by hand.";
fn drop_nulls(v: &Value) -> Value {
match v {
Value::Object(m) => Value::Object(m.iter().filter(|(_, v)| !v.is_null()).map(|(k, v)| (k.clone(), drop_nulls(v))).collect()),
Value::Array(a) => Value::Array(a.iter().filter(|v| !v.is_null()).map(drop_nulls).collect()),
other => other.clone(),
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct LLM {
pub role: String,
pub provider: String,
pub model: String,
#[serde(rename = "resolved-from")]
pub resolved_from: Option<String>,
#[serde(default)]
pub params: BTreeMap<String, serde_json::Value>,
pub fingerprint: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub endpoint: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub runtime: Option<String>,
}
impl Default for LLM {
fn default() -> Self {
LLM {
role: String::new(),
provider: "reference".into(),
model: String::new(),
resolved_from: None,
params: BTreeMap::new(),
fingerprint: None,
endpoint: None,
runtime: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
#[serde(default, rename_all = "kebab-case")]
pub struct Decider {
#[serde(rename = "use")]
pub use_: String,
pub provider: String,
pub model: String,
pub protocol: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub endpoint: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub key_env: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub runtime: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub resolved_from: Option<String>,
pub via: String,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub wiring: Vec<Wiring>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub may: Vec<String>,
#[serde(skip_serializing_if = "serde_json::Value::is_null")]
pub min_probability: serde_json::Value,
#[serde(skip_serializing_if = "Option::is_none")]
pub timeout_ms: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub questions: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub policy: Option<PolicyRef>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
#[serde(default)]
pub struct Wiring {
pub file: String,
pub event: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
#[serde(default)]
pub struct PolicyRef {
pub path: String,
pub digest: String,
}
#[derive(Debug, Clone, Serialize, Default, PartialEq)]
#[serde(rename_all = "kebab-case")]
pub struct EvidenceJudge {
pub model: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub answered: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub protocol: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub fallback: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub uncertain_grades: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cost_usd_per_task: Option<f64>,
}
impl<'de> Deserialize<'de> for EvidenceJudge {
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> std::result::Result<Self, D::Error> {
#[derive(Deserialize)]
#[serde(default, rename_all = "kebab-case")]
#[derive(Default)]
struct T {
model: String,
answered: Option<String>,
protocol: Option<String>,
fallback: Option<String>,
uncertain_grades: Option<f64>,
cost_usd_per_task: Option<f64>,
}
#[derive(Deserialize)]
#[serde(untagged)]
enum E {
S(String),
T(T),
}
Ok(match E::deserialize(d)? {
E::S(model) => EvidenceJudge { model, ..Default::default() },
E::T(t) => EvidenceJudge { model: t.model, answered: t.answered, protocol: t.protocol, fallback: t.fallback, uncertain_grades: t.uncertain_grades, cost_usd_per_task: t.cost_usd_per_task },
})
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(default)]
pub struct Decision {
pub kind: String,
pub model: Option<String>,
pub revision: Option<String>,
#[serde(default)]
pub decisions: Vec<String>,
#[serde(default)]
pub thresholds: BTreeMap<String, f64>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(default)]
pub struct Harness {
pub name: String,
pub version: Option<String>,
pub source: Option<String>,
pub integrity: Option<String>,
pub adapter: Option<String>,
pub config: Option<String>,
pub pattern: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
#[serde(default)]
pub struct Tool {
pub name: String,
pub kind: String,
pub source: Option<String>,
pub integrity: Option<String>,
#[serde(rename = "schema")]
pub tool_schema: Option<String>,
#[serde(default)]
pub permissions: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub transport: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub command: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub args: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub url: Option<String>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub env: BTreeMap<String, String>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub headers: BTreeMap<String, String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub scope: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub enabled: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub version: Option<String>,
#[serde(flatten)]
pub dep: Declared,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
#[serde(default)]
pub struct Skill {
pub name: String,
pub origin: String,
pub source: Option<String>,
pub digest: Option<String>,
pub registry: Option<String>,
#[serde(rename = "provided-by")]
pub provided_by: Option<String>,
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default)]
pub editable: bool,
#[serde(rename = "evidence-digest")]
pub evidence_digest: Option<String>,
#[serde(default)]
pub requires: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub scope: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub path: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub vendored: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub scan: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub scripts: Option<Vec<String>>,
#[serde(flatten)]
pub dep: Declared,
}
fn default_true() -> bool {
true
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
#[serde(default, rename_all = "kebab-case")]
pub struct Declared {
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub declared: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub constraint: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub resolved_tag: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub resolved_ref: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub commit: Option<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub required_by: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub declared_as: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub evidence: Option<DepEvidence>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
#[serde(default, rename_all = "kebab-case")]
pub struct Package {
pub name: String,
pub source: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub digest: Option<String>,
pub depth: u32,
#[serde(skip_serializing_if = "Option::is_none")]
pub version: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub license: Option<String>,
#[serde(skip_serializing_if = "BTreeMap::is_empty")]
pub skipped: BTreeMap<String, usize>,
#[serde(flatten)]
pub dep: Declared,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
#[serde(default, rename_all = "kebab-case")]
pub struct DepEvidence {
pub eval_pack: Option<String>,
pub split: String,
pub trials: usize,
pub baseline_success_rate: f64,
pub success_rate: f64,
#[serde(skip_serializing_if = "Option::is_none")]
pub baseline_cost_usd_per_task: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cost_usd_per_task: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub digest: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
#[serde(default)]
pub struct Instructions {
pub name: String,
pub digest: Option<String>,
pub scope: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub vendored: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub scan: Option<String>,
#[serde(flatten)]
pub dep: Declared,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
#[serde(default)]
pub struct Native {
pub kind: String,
pub name: String,
pub version: Option<String>,
pub source: Option<String>,
pub revision: Option<String>,
pub integrity: Option<String>,
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub config: Option<serde_json::Value>,
#[serde(flatten)]
pub dep: Declared,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
#[serde(default)]
pub struct Runtime {
pub name: String,
pub location: String,
pub version: Option<String>,
pub source: Option<String>,
pub endpoint: Option<String>,
#[serde(default)]
pub model: Vec<RuntimeModel>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
#[serde(default)]
pub struct RuntimeModel {
pub id: String,
pub source: Option<String>,
pub revision: Option<String>,
pub digest: Option<String>,
#[serde(rename = "size-bytes")]
pub size_bytes: Option<u64>,
pub present: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
#[serde(default)]
pub struct ConfigFile {
pub id: String,
pub path: String,
pub format: String,
pub restore: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub pointers: Vec<String>,
pub digest: Option<String>,
pub vendored: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub scan: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
#[serde(default, rename_all = "kebab-case")]
pub struct SecretRef {
pub name: String,
pub used_by: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(default, rename_all = "kebab-case")]
pub struct Policy {
pub retries: u32,
pub tool_timeout_seconds: Option<u64>,
pub max_steps: u32,
pub max_cost_usd_per_task: Option<f64>,
pub sandbox: BTreeMap<String, serde_json::Value>,
pub human_approval: String,
pub risky_actions: Vec<String>,
pub tool_allowlist: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(default)]
pub struct Context {
pub isolation: String,
pub compaction: BTreeMap<String, serde_json::Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(default, rename_all = "kebab-case")]
pub struct Evidence {
#[serde(alias = "task-pack")]
pub eval_pack: Option<String>,
#[serde(alias = "task-pack-version")]
pub eval_pack_version: Option<String>,
pub split: String,
pub trials: usize,
pub success_rate: BTreeMap<String, f64>,
pub cost_usd_per_task: BTreeMap<String, f64>,
pub latency_s_per_task: BTreeMap<String, f64>,
pub judge: Option<EvidenceJudge>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(default, rename_all = "kebab-case")]
pub struct Verify {
pub canary_tasks: usize,
pub min_success_rate: f64,
pub fingerprint_tolerance: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(default)]
pub struct Provenance {
pub backend: String,
pub run: Option<String>,
#[serde(default)]
pub search: BTreeMap<String, serde_json::Value>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub scopes: Vec<String>,
#[serde(default, rename = "user-include", skip_serializing_if = "Vec::is_empty")]
pub user_include: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub openapm: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub struct Lockfile {
pub lock_version: i64,
pub generated_by: String,
pub generated_at: String,
pub manifest_hash: Option<String>,
#[serde(default)]
pub stack: BTreeMap<String, serde_json::Value>,
#[serde(default)]
pub llm: Vec<LLM>,
#[serde(default)]
pub decision: Decision,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub decider: Vec<Decider>,
#[serde(default)]
pub harness: Harness,
#[serde(default)]
pub tool: Vec<Tool>,
#[serde(default)]
pub skill: Vec<Skill>,
#[serde(default)]
pub instructions: Vec<Instructions>,
#[serde(default)]
pub policy: Policy,
#[serde(default)]
pub context: Context,
#[serde(default)]
pub evidence: Evidence,
#[serde(default)]
pub verify: Verify,
#[serde(default)]
pub provenance: Provenance,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub native: Vec<Native>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub runtime: Vec<Runtime>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub config: Vec<ConfigFile>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub secret: Vec<SecretRef>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub package: Vec<Package>,
#[serde(default, skip_serializing_if = "Option::is_none", rename = "base-lock")]
pub base_lock: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub kind: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tune: Option<serde_json::Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub localize: Option<serde_json::Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pulled: Option<serde_json::Value>,
}
impl Default for Lockfile {
fn default() -> Self {
Lockfile {
lock_version: LOCK_VERSION,
generated_by: format!("harness-lock {}", env!("CARGO_PKG_VERSION")),
generated_at: String::new(),
manifest_hash: None,
stack: BTreeMap::new(),
llm: Vec::new(),
decision: Decision::default(),
decider: Vec::new(),
harness: Harness::default(),
tool: Vec::new(),
skill: Vec::new(),
instructions: Vec::new(),
policy: Policy::default(),
context: Context::default(),
evidence: Evidence::default(),
verify: Verify::default(),
provenance: Provenance::default(),
native: Vec::new(),
runtime: Vec::new(),
config: Vec::new(),
secret: Vec::new(),
package: Vec::new(),
base_lock: None,
kind: None,
tune: None,
localize: None,
pulled: None,
}
}
}
impl Lockfile {
pub fn load(path: &Path) -> Result<Lockfile> {
let text = std::fs::read_to_string(path)
.map_err(|e| Error::Lockfile(format!("cannot read {}: {e}", path.display())))?;
Lockfile::from_str(&text)
}
pub fn from_str(text: &str) -> Result<Lockfile> {
let lf: Lockfile =
toml::from_str(text).map_err(|e| Error::Lockfile(format!("invalid harness.lock: {e}")))?;
if lf.lock_version != LOCK_VERSION {
return Err(Error::Lockfile(format!(
"unsupported lock-version {} (this build supports {})",
lf.lock_version, LOCK_VERSION
)));
}
lf.validate()?;
Ok(lf)
}
pub fn validate(&self) -> Result<()> {
use crate::fsutil::{plain_name, plain_token, safe_rel};
let name = |what: &str, s: &str| -> Result<()> {
if plain_name(s) { Ok(()) } else { Err(Error::Lockfile(format!("{what} {s:?} must be a plain name (no '/', '\\', or '..')"))) }
};
let rel = |what: &str, s: &str| -> Result<()> {
if safe_rel(s) { Ok(()) } else { Err(Error::Lockfile(format!("{what} {s:?} must be a relative path inside the project (no leading '/', no '..')"))) }
};
let token = |what: &str, s: &str| -> Result<()> {
if plain_token(s) {
Ok(())
} else {
Err(Error::Lockfile(format!("{what} {s:?} has characters that aren't allowed (letters, digits and ._:/@+- only, not starting with '-')")))
}
};
if !self.harness.name.is_empty() {
name("[harness] name", &self.harness.name)?;
}
if let Some(v) = &self.harness.version {
token("[harness] version", v)?;
}
let dep = |what: &str, d: &Declared| -> Result<()> {
for (k, v) in [("commit", &d.commit), ("resolved-tag", &d.resolved_tag), ("resolved-ref", &d.resolved_ref)] {
if let Some(v) = v {
token(&format!("{what} {k}"), v)?;
}
}
if let Some(n) = &d.declared_as {
name(&format!("{what} declared-as"), n)?;
}
if let Some(c) = d.constraint.as_deref().filter(|c| c.chars().any(char::is_control)) {
return Err(Error::Lockfile(format!("{what} constraint {c:?} has control characters")));
}
Ok(())
};
for s in &self.skill {
name("[[skill]] name", &s.name)?;
if let Some(v) = &s.vendored {
rel(&format!("[[skill]] {} vendored", s.name), v)?;
}
dep(&format!("[[skill]] {}", s.name), &s.dep)?;
}
for i in &self.instructions {
if let Some(v) = &i.vendored {
rel(&format!("[[instructions]] {} vendored", i.name), v)?;
}
dep(&format!("[[instructions]] {}", i.name), &i.dep)?;
}
for p in &self.package {
name("[[package]] name", &p.name)?;
dep(&format!("[[package]] {}", p.name), &p.dep)?;
}
for t in &self.tool {
if let Some(v) = &t.version {
token(&format!("[[tool]] {} version", t.name), v)?;
}
dep(&format!("[[tool]] {}", t.name), &t.dep)?;
}
for c in &self.config {
if let Some(v) = &c.vendored {
rel(&format!("[[config]] {} vendored", c.id), v)?;
}
}
for n in &self.native {
if let Some(v) = &n.version {
token(&format!("[[native]] {} version", n.name), v)?;
}
dep(&format!("[[native]] {}", n.name), &n.dep)?;
}
for r in &self.runtime {
token("[[runtime]] name", &r.name)?;
if let Some(v) = &r.version {
token(&format!("[[runtime]] {} version", r.name), v)?;
}
for m in &r.model {
token(&format!("[[runtime]] {} model id", r.name), &m.id)?;
if let Some(src) = &m.source {
token(&format!("[[runtime]] {} model {} source", r.name, m.id), src)?;
}
if let Some(rev) = &m.revision {
token(&format!("[[runtime]] {} model {} revision", r.name, m.id), rev)?;
}
}
}
for l in &self.llm {
if let Some(rt) = &l.runtime {
token(&format!("[[llm]] {} runtime", l.role), rt)?;
}
}
for d in &self.decider {
if !crate::decide::AGENT_USES.contains(&d.use_.as_str()) {
return Err(Error::Lockfile(format!("[[decider]] use {:?} isn't one of {}", d.use_, crate::decide::AGENT_USES.join(", "))));
}
token(&format!("[[decider]] {} model", d.use_), &d.model)?;
if let Some(rt) = &d.runtime {
token(&format!("[[decider]] {} runtime", d.use_), rt)?;
}
for w in &d.wiring {
token(&format!("[[decider]] {} wiring event", d.use_), &w.event)?;
}
if let Some(p) = &d.policy {
rel(&format!("[[decider]] {} policy path", d.use_), &p.path)?;
}
}
Ok(())
}
pub fn save(&self, path: &Path) -> Result<()> {
let body = toml::to_string(&self.without_nulls()).map_err(|e| Error::Lockfile(e.to_string()))?;
crate::fsutil::write_atomic(path, format!("{HEADER}\n{body}")).map_err(|e| crate::error::at(path, e))
}
fn without_nulls(&self) -> Lockfile {
fn map(m: &mut BTreeMap<String, Value>) {
m.retain(|_, v| !v.is_null());
m.values_mut().for_each(|v| *v = drop_nulls(v));
}
fn opt(o: &mut Option<Value>) {
match o {
Some(v) if v.is_null() => *o = None,
Some(v) => *v = drop_nulls(v),
None => {}
}
}
let mut l = self.clone();
map(&mut l.stack);
l.llm.iter_mut().for_each(|x| map(&mut x.params));
l.decider.iter_mut().for_each(|d| d.min_probability = drop_nulls(&d.min_probability));
map(&mut l.policy.sandbox);
map(&mut l.context.compaction);
map(&mut l.provenance.search);
l.native.iter_mut().for_each(|n| opt(&mut n.config));
opt(&mut l.tune);
opt(&mut l.localize);
opt(&mut l.pulled);
l
}
pub fn is_pin(&self) -> bool {
self.kind.as_deref() == Some("pin")
}
pub fn pins_user_scope(&self) -> bool {
self.provenance.scopes.is_empty() || self.provenance.scopes.iter().any(|s| s == "user")
}
pub fn default_path(&self) -> &str {
LOCKFILE_PATH
}
pub fn check_manifest(&self, live_hash: &str) -> Vec<String> {
match &self.manifest_hash {
None => vec!["lock has no manifest-hash".to_string()],
Some(pinned) => {
let pinned_hex = pinned.split(':').nth(1).unwrap_or(pinned);
let live_hex = live_hash.split(':').nth(1).unwrap_or(live_hash);
if pinned_hex == live_hex {
Vec::new()
} else {
vec![format!(
"manifest drift: lock pinned {pinned:?}, live {live_hash:?}"
)]
}
}
}
}
pub fn summary(&self) -> String {
let model = self.llm.first().map(|l| l.model.as_str()).unwrap_or("?");
let sr = self.evidence.success_rate.get("mean").unwrap_or(&f64::NAN);
format!(
"stack={:?} harness={} llm={} decision={} success-rate={:.2} trials={}",
self.stack.get("name").and_then(serde_json::Value::as_str).unwrap_or("agent"),
self.harness.name,
model,
self.decision.kind,
sr,
self.evidence.trials
)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn tmp(tag: &str) -> std::path::PathBuf {
let d = std::env::temp_dir().join(format!("hlock-lockfile-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
d
}
const HEAD: &str = "lock-version = 1\ngenerated-by = \"t\"\ngenerated-at = \"\"\n";
#[test]
fn lines_starting_with_hash_inside_strings_survive() {
let mut l = Lockfile::default();
l.stack.insert("notes".into(), Value::String("line one\n# not a comment\nline three".into()));
let d = tmp("hash");
let p = d.join("harness.lock");
l.save(&p).unwrap();
let text = std::fs::read_to_string(&p).unwrap();
assert!(text.starts_with(HEADER), "the header is written");
let back = Lockfile::load(&p).unwrap();
assert_eq!(back.stack["notes"], "line one\n# not a comment\nline three");
let raw = format!("{HEADER}\n{HEAD}[stack]\nnotes = \"\"\"\na\n# b\nc\"\"\"\n");
assert_eq!(Lockfile::from_str(&raw).unwrap().stack["notes"], "a\n# b\nc");
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn nulls_in_free_form_fields_are_dropped_on_save() {
let mut l = Lockfile::default();
l.stack.insert("gone".into(), Value::Null);
l.tune = Some(serde_json::json!({"data_meta": {"rows": null, "split": [0.7, null]}, "kept": 1}));
l.pulled = Some(Value::Null);
l.provenance.search.insert("x".into(), serde_json::json!({"y": null}));
l.native.push(Native { kind: "pi.package".into(), name: "p".into(), config: Some(serde_json::json!({"a": null})), ..Default::default() });
let d = tmp("nulls");
let p = d.join("harness.lock");
l.save(&p).unwrap();
let back = Lockfile::load(&p).unwrap();
assert!(!back.stack.contains_key("gone") && back.pulled.is_none());
assert_eq!(back.tune.unwrap(), serde_json::json!({"data_meta": {"split": [0.7]}, "kept": 1}));
assert_eq!(std::fs::read_dir(&d).unwrap().count(), 1, "written by rename; no temp file left");
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn names_that_become_paths_or_commands_are_refused() {
for (body, field) in [
("[harness]\nname = \"../../x\"\n", "[harness] name"),
("[harness]\nname = \"pi\"\nversion = \"1; curl x|sh\"\n", "[harness] version"),
("[[skill]]\nname = \"../evil\"\norigin = \"x\"\n", "[[skill]] name"),
("[[skill]]\nname = \"s\"\norigin = \"x\"\nvendored = \"/etc\"\n", "vendored"),
("[[instructions]]\nname = \"AGENTS.md\"\nscope = \"project\"\nvendored = \"../../.zshrc\"\n", "vendored"),
("[[config]]\nid = \"settings\"\npath = \"x\"\nformat = \"json\"\nrestore = \"whole\"\nvendored = \"a/../../b\"\n", "vendored"),
("[[runtime]]\nname = \"ollama\"\nlocation = \"local\"\n[[runtime.model]]\nid = \"--help\"\n", "model id"),
("[[decider]]\nuse = \"judge\"\nmodel = \"kev\"\n", "[[decider]] use"),
("[[decider]]\nuse = \"stop\"\nmodel = \"kev $(id)\"\n", "[[decider]] stop model"),
("[[decider]]\nuse = \"stop\"\nmodel = \"kev\"\n[[decider.wiring]]\nfile = \"settings\"\nevent = \"Stop; rm\"\n", "wiring event"),
("[[decider]]\nuse = \"stop\"\nmodel = \"kev\"\n[decider.policy]\npath = \"/etc/passwd\"\ndigest = \"\"\n", "policy path"),
] {
let e = Lockfile::from_str(&format!("{HEAD}{body}")).unwrap_err().to_string();
assert!(e.contains(field), "{body}: {e}");
}
let ok = format!(
"{HEAD}[harness]\nname = \"claude-code\"\nversion = \"2.1.0\"\n[[instructions]]\nname = \"~/.claude/CLAUDE.md\"\nscope = \"user\"\n\
[[runtime]]\nname = \"ollama\"\nlocation = \"local\"\n[[runtime.model]]\nid = \"qwen3:8b\"\nsource = \"ollama:qwen3:8b\"\n\
[[decider]]\nuse = \"approve\"\nmodel = \"kev:4b\"\nruntime = \"ollama\"\n"
);
Lockfile::from_str(&ok).unwrap();
}
}