main
Caleb Add the Rust source, flow definition, assets and install scripts cf9809b
//! Every claim the flow makes is an item: a part, a block, a zone, a trace, a via, a net, a plane, the
//! outline, the layer proposal. Each item carries an evidence tag saying how it is known, the files
//! that show it, and (when it is not seen) the tests that would settle it. The checks and the report
//! read items the same way whichever artifact they come from.
use serde_json::Value;
use std::path::{Path, PathBuf};

/// How an item is known, strongest first.
pub const TAGS: [&str; 5] = ["SEEN", "MEASURED", "DATASHEET", "INFERRED", "UNKNOWN"];

/// (artifact file, array key, id key, id prefix): where items live.
pub const KINDS: &[(&str, &str, &str, &str)] = &[
    ("parts.json", "parts", "ref", ""),
    ("blocks.json", "blocks", "id", "block:"),
    ("copper.json", "zones", "id", "zone:"),
    ("copper.json", "traces", "id", "trace:"),
    ("copper.json", "vias", "id", "via:"),
    ("nets.json", "nets", "name", "net:"),
    ("layers.json", "planes", "layer", "plane:"),
];

/// The artifacts that hold items, in flow order.
pub const ARTIFACTS: [&str; 5] = ["parts.json", "blocks.json", "copper.json", "nets.json", "layers.json"];

#[derive(Clone, Debug)]
pub struct Item {
    pub id: String,
    pub artifact: String,
    pub kind: String,
    pub tag: String,
    /// Per-field tags (`fieldTags: {"value": "INFERRED"}`), for an item seen in part and inferred in part.
    pub field_tags: Vec<(String, String)>,
    pub tests: Vec<String>,
    pub evidence: Vec<String>,
    pub source: String,
}

pub fn is_open(tag: &str) -> bool { tag == "INFERRED" || tag == "UNKNOWN" }

impl Item {
    /// The item is not fully known: its own tag or one of its field tags is INFERRED or UNKNOWN.
    pub fn open(&self) -> bool { is_open(&self.tag) || self.field_tags.iter().any(|(_, t)| is_open(t)) }
    /// The tags of the item and its fields that are open, for messages.
    pub fn open_what(&self) -> Vec<String> {
        let mut v = vec![];
        if is_open(&self.tag) { v.push(self.tag.clone()); }
        for (f, t) in &self.field_tags { if is_open(t) { v.push(format!("{f} {t}")); } }
        v
    }
}

fn strs(v: &Value) -> Vec<String> {
    match v {
        Value::String(s) if !s.is_empty() => vec![s.clone()],
        Value::Array(a) => a.iter().filter_map(|x| x.as_str().map(str::to_string)).filter(|s| !s.is_empty()).collect(),
        _ => vec![],
    }
}

fn item(id: String, artifact: &str, kind: &str, v: &Value, default_evidence: &[String]) -> Item {
    let mut evidence = strs(&v["evidence"]);
    if evidence.is_empty() { evidence = default_evidence.to_vec(); }
    let field_tags = v["fieldTags"].as_object().map(|o| o.iter().map(|(k, t)| (k.clone(), t.as_str().unwrap_or("").to_string())).collect()).unwrap_or_default();
    Item {
        id, artifact: artifact.into(), kind: kind.into(),
        tag: v["tag"].as_str().unwrap_or("").to_string(),
        field_tags, tests: strs(&v["tests"]), evidence,
        source: v["source"].as_str().unwrap_or("").to_string(),
    }
}

/// The items in one artifact. Arrays per KINDS, plus the singletons: copper.json `outline` and the
/// layer proposal (layers.json's own tag).
pub fn items_of(artifact: &str, doc: &Value) -> Vec<Item> {
    let dflt = strs(&doc["defaultEvidence"]);
    let mut out = vec![];
    for (file, key, idk, pre) in KINDS {
        if *file != artifact { continue; }
        for v in doc[*key].as_array().into_iter().flatten() {
            let id = v[*idk].as_str().unwrap_or("").to_string();
            out.push(item(format!("{pre}{id}"), artifact, key, v, &dflt));
        }
    }
    if artifact == "copper.json" && doc["outline"].is_object() { out.push(item("outline".into(), artifact, "outline", &doc["outline"], &dflt)); }
    if artifact == "layers.json" && doc.get("proposed").is_some() { out.push(item("layers".into(), artifact, "layers", doc, &dflt)); }
    out
}

pub fn read_json(p: &Path) -> Result<Value, String> {
    let t = std::fs::read_to_string(p).map_err(|e| format!("{}: {e}", p.display()))?;
    serde_json::from_str(&t).map_err(|e| format!("{}: not valid JSON: {e}", p.display()))
}

/// Every item in every artifact that exists in the flow folder.
pub fn all(flow_dir: &Path) -> Vec<Item> {
    ARTIFACTS.iter().filter_map(|a| read_json(&flow_dir.join(a)).ok().map(|d| items_of(a, &d))).flatten().collect()
}

/// An evidence path: absolute, or relative to the flow folder, or to the project folder above it.
pub fn resolve(flow_dir: &Path, rel: &str) -> Option<PathBuf> {
    let p = Path::new(rel);
    if p.is_absolute() { return p.exists().then(|| p.to_path_buf()); }
    let a = flow_dir.join(p);
    if a.exists() { return Some(a); }
    let b = flow_dir.parent().map(|d| d.join(p))?;
    b.exists().then_some(b)
}

/// The rules every item meets, whatever artifact it is in. Problems are pushed to `errs`.
pub fn validate(flow_dir: &Path, it: &Item, errs: &mut Vec<String>) {
    let id = if it.id.is_empty() || it.id.ends_with(':') { format!("an item in {} with no id", it.artifact) } else { it.id.clone() };
    if !TAGS.contains(&it.tag.as_str()) {
        errs.push(format!("{id}: tag {:?} is not one of {}", it.tag, TAGS.join(", ")));
    }
    for (f, t) in &it.field_tags {
        if !TAGS.contains(&t.as_str()) { errs.push(format!("{id}: fieldTags.{f} {t:?} is not one of {}", TAGS.join(", "))); }
    }
    if matches!(it.tag.as_str(), "SEEN" | "MEASURED") {
        if it.evidence.is_empty() { errs.push(format!("{id}: tagged {} but names no evidence file (a crop that shows it)", it.tag)); }
    }
    for e in &it.evidence {
        if resolve(flow_dir, e).is_none() { errs.push(format!("{id}: evidence file {e} is not there")); }
    }
    let ds = it.tag == "DATASHEET" || it.field_tags.iter().any(|(_, t)| t == "DATASHEET");
    if ds && it.source.is_empty() { errs.push(format!("{id}: tagged DATASHEET but has no source (document, revision, page or table)")); }
    if it.open() && it.tests.is_empty() {
        errs.push(format!("{id}: {} but names no test that would settle it (tests: [\"T..\"])", it.open_what().join(", ")));
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use serde_json::json;

    #[test]
    fn items_and_rules() {
        let d = std::env::temp_dir().join(format!("items-test-{}", std::process::id()));
        std::fs::create_dir_all(d.join("evidence")).unwrap();
        std::fs::write(d.join("evidence/u1.jpg"), "x").unwrap();
        let doc = json!({"parts": [
            {"ref": "U1", "tag": "SEEN", "evidence": ["evidence/u1.jpg"], "fieldTags": {"mpn": "INFERRED"}, "tests": ["T1"]},
            {"ref": "R1", "tag": "INFERRED"},
            {"ref": "C1", "tag": "DATASHEET"},
            {"ref": "Q1", "tag": "SEEN", "evidence": ["evidence/missing.jpg"]}
        ]});
        let items = items_of("parts.json", &doc);
        assert_eq!(items.len(), 4);
        let mut e = vec![];
        for it in &items { validate(&d, it, &mut e); }
        assert!(e.iter().any(|m| m.starts_with("R1:") && m.contains("no test")), "{e:?}");
        assert!(e.iter().any(|m| m.starts_with("C1:") && m.contains("no source")), "{e:?}");
        assert!(e.iter().any(|m| m.starts_with("Q1:") && m.contains("not there")), "{e:?}");
        assert!(!e.iter().any(|m| m.starts_with("U1:")), "{e:?}");
        assert!(items[0].open());
        std::fs::remove_dir_all(&d).ok();
    }
}