use std::collections::BTreeMap;
use super::diff::{self, Answers, Line, MapBlock, Params};
use crate::git_xrpc::Rows;
use crate::nginx::conf::Block;
const DID: &str = "did:plc:aaaaaaaaaaaaaaaaaaaaaaaa";
const ONE: &str = "1111111111111111111111111111111111111111";
const TWO: &str = "2222222222222222222222222222222222222222";
fn rows(pairs: &[(&str, &str)]) -> BTreeMap<String, String> {
pairs
.iter()
.map(|(key, value)| (key.to_string(), value.to_string()))
.collect()
}
fn params(pairs: &[(&str, &str)]) -> Params {
pairs
.iter()
.map(|(key, value)| (key.to_string(), value.to_string()))
.collect()
}
fn git(rows: &Rows) -> Answers {
let mut answers = Answers::default();
diff::git_answers(DID, rows, None, &mut answers);
answers
}
fn lines(old: &Answers, new: &Answers) -> Vec<Line> {
let mut lines = Vec::new();
diff::compare(old, new, &mut lines);
lines
}
type Op<'a> = (&'a str, &'a str, Params);
fn ops(lines: &[Line]) -> std::collections::BTreeSet<Op<'_>> {
lines
.iter()
.map(|line| (line.op, line.nsid.unwrap_or(""), line.params.clone()))
.collect()
}
fn set<'a>(ops: impl IntoIterator<Item = Op<'a>>) -> std::collections::BTreeSet<Op<'a>> {
ops.into_iter().collect()
}
#[test]
fn decode_takes_only_spellings_the_knot_writes() {
assert_eq!(diff::decode("a%20b").as_deref(), Some("a b"));
assert_eq!(diff::decode("a+b").as_deref(), Some("a b"));
assert_eq!(diff::decode("a b").as_deref(), Some("a b"));
assert_eq!(
diff::decode("src%2Fmain.rs").as_deref(),
Some("src/main.rs")
);
assert_eq!(diff::decode("src/main.rs").as_deref(), Some("src/main.rs"));
assert_eq!(
diff::decode("notes%20(1).md").as_deref(),
Some("notes (1).md")
);
assert_eq!(diff::decode("100%").as_deref(), Some("100%"));
assert_eq!(diff::decode("%zz"), None);
}
#[test]
fn ref_spellings_collapse_to_short_names() {
let tip = Rows {
commit: rows(&[
("", ONE),
("HEAD", ONE),
("main", ONE),
("refs/heads/main", ONE),
("refs%2Fheads%2Fmain", ONE),
(ONE, ONE),
("v1", TWO),
("refs/tags/v1", TWO),
// `x` is a branch and a tag, and the short name picks the branch.
("x", ONE),
("refs/heads/x", ONE),
("refs/tags/x", TWO),
]),
languages: rows(&[(ONE, "json/l1.json"), (TWO, "json/l2.json")]),
..Rows::default()
};
let answers = git(&tip);
let mut refs: Vec<Option<String>> = Vec::new();
for line in lines(&Answers::default(), &answers) {
refs.push(line.params.get("ref").cloned());
}
let refs: std::collections::BTreeSet<Option<&str>> =
refs.iter().map(|r| r.as_deref()).collect();
assert_eq!(
refs,
std::collections::BTreeSet::from([
None,
Some("HEAD"),
Some("main"),
Some("x"),
Some("refs/tags/x"),
Some("v1"),
]),
"added rows of a full oid are left out, one line per other spelling"
);
}
#[test]
fn changed_added_and_removed_answers_are_listed_once_per_spelling() {
let commit = |oid: &str| rows(&[("main", oid), ("refs/heads/main", oid), (oid, oid)]);
let old = Rows {
commit: commit(ONE),
tree: rows(&[
(&format!("{ONE}|"), "json/root-a.json"),
(&format!("{ONE}|src"), "json/src.json"),
(&format!("{ONE}|gone"), "json/gone.json"),
]),
branches: rows(&[("100|", "json/b1.json"), ("100|0", "json/b1.json")]),
..Rows::default()
};
let new = Rows {
commit: commit(TWO),
tree: rows(&[
(&format!("{TWO}|"), "json/root-b.json"),
(&format!("{TWO}|src"), "json/src.json"),
(&format!("{TWO}|new%20dir"), "json/new.json"),
]),
branches: rows(&[("100|", "json/b2.json"), ("100|0", "json/b2.json")]),
..Rows::default()
};
let got = lines(&git(&old), &git(&new));
let tree = "org.tangled.git.getTree";
let branches = "org.tangled.git.listBranches";
assert_eq!(
ops(&got),
set([
(
"changed",
branches,
params(&[("repo", DID), ("limit", "100")])
),
(
"changed",
branches,
params(&[("repo", DID), ("limit", "100"), ("cursor", "0")])
),
("removed", tree, params(&[("repo", DID), ("ref", ONE)])),
(
"removed",
tree,
params(&[("repo", DID), ("ref", ONE), ("path", "gone")])
),
(
"removed",
tree,
params(&[("repo", DID), ("ref", ONE), ("path", "src")])
),
("changed", tree, params(&[("repo", DID), ("ref", "main")])),
(
"removed",
tree,
params(&[("repo", DID), ("ref", "main"), ("path", "gone")])
),
(
"added",
tree,
params(&[("repo", DID), ("ref", "main"), ("path", "new dir")])
),
]),
"src kept its file under main, so it isn't listed"
);
let root = got
.iter()
.find(|line| line.op == "changed" && line.nsid == Some(tree))
.unwrap();
let token = crate::plan::repo_token(DID);
assert_eq!(
root.old.as_deref(),
Some(format!("_git_xrpc/{token}/json/root-a.json").as_str())
);
assert_eq!(
root.new.as_deref(),
Some(format!("_git_xrpc/{token}/json/root-b.json").as_str())
);
}
#[test]
fn skipping_alike_rows_keeps_every_line() {
let commit = |oid: &str| {
rows(&[
("main", oid),
("refs/heads/main", oid),
(oid, oid),
("v1", ONE),
])
};
let side = |oid: &str, root: &str| Rows {
commit: commit(oid),
tree: rows(&[
(&format!("{oid}|"), root),
(&format!("{oid}|src"), "json/src.json"),
(&format!("{ONE}|docs"), "json/docs.json"),
]),
log: rows(&[(&format!("{oid}||60|"), root)]),
diff: rows(&[(&format!("{ONE}|{oid}"), root)]),
..Rows::default()
};
let (old, new) = (side(ONE, "json/a.json"), side(TWO, "json/b.json"));
let full = lines(&git(&old), &git(&new));
let (mut before, mut after) = (Answers::default(), Answers::default());
diff::git_answers(DID, &old, Some(&new), &mut before);
diff::git_answers(DID, &new, Some(&old), &mut after);
let skipped = lines(&before, &after);
assert!(!full.is_empty());
assert_eq!(full, skipped);
}
#[test]
fn identical_answers_make_an_empty_diff() {
let tip = Rows {
commit: rows(&[("main", ONE), (ONE, ONE)]),
tree: rows(&[(&format!("{ONE}|"), "json/root.json")]),
log: rows(&[(&format!("{ONE}||60|"), "json/log.json")]),
..Rows::default()
};
assert!(lines(&git(&tip), &git(&tip)).is_empty());
}
#[test]
fn reads_that_need_a_ref_skip_the_empty_spelling() {
let tip = Rows {
commit: rows(&[("", ONE), ("main", ONE)]),
log: rows(&[(&format!("{ONE}||60|"), "json/log.json")]),
blame: rows(&[(&format!("{ONE}|a.rs"), "json/blame.json")]),
..Rows::default()
};
let got = lines(&Answers::default(), &git(&tip));
assert_eq!(
ops(&got),
set([
(
"added",
"org.tangled.git.getBlame",
params(&[("repo", DID), ("ref", "main"), ("path", "a.rs")])
),
(
"added",
"org.tangled.git.listCommits",
params(&[("repo", DID), ("ref", "main"), ("limit", "60")])
),
])
);
}
#[test]
fn printed_maps_read_back() {
let printed = Block::file()
.block(crate::xrpc::map_with_default(
"social/empty.json",
"$arg_subject$arg_repo|$arg_state",
"$ks_soc_issues",
vec![
("a b|open".into(), "social/1.json".into()),
("\"quoted\";|".into(), "social/2.json".into()),
],
))
.block(crate::xrpc::map("$arg_did", "$ks_atp_did", Vec::new()))
.to_string();
assert_eq!(
diff::parse_maps(&printed),
[
MapBlock {
source: "$arg_subject$arg_repo|$arg_state".into(),
target: "$ks_soc_issues".into(),
default: "social/empty.json".into(),
rows: vec![
("\"quoted\";|".into(), "social/2.json".into()),
("a b|open".into(), "social/1.json".into()),
],
},
MapBlock {
source: "$arg_did".into(),
target: "$ks_atp_did".into(),
default: String::new(),
rows: Vec::new(),
},
]
);
}
fn social(maps: Vec<MapBlock>) -> Answers {
let mut answers = Answers::default();
diff::social_answers(&maps, &[DID], &mut answers);
answers
}
fn issues(default: &str, rows: &[(&str, &str)]) -> MapBlock {
MapBlock {
source: "$arg_subject$arg_repo|$arg_state".into(),
target: "$ks_soc_issues".into(),
default: default.into(),
rows: rows
.iter()
.map(|(key, value)| (key.to_string(), value.to_string()))
.collect(),
}
}
#[test]
fn social_reads_list_every_argument_name_with_the_repo_did() {
let manifest = crate::xrpc::form_encoded(&format!("at://{DID}/org.tangled.repo.manifest/self"));
let did = crate::xrpc::form_encoded(DID);
let old = social(vec![issues(
"social/empty.json",
&[
(&format!("{manifest}|open"), "social/a.json"),
(&format!("{did}|open"), "social/a.json"),
],
)]);
let new = social(vec![issues(
"social/empty.json",
&[
(&format!("{manifest}|open"), "social/b.json"),
(&format!("{did}|open"), "social/b.json"),
(&format!("{did}|"), "social/c.json"),
],
)]);
let nsid = "org.tangled.repo.listIssues";
assert_eq!(
ops(&lines(&old, &new)),
set([
("added", nsid, params(&[("repo", DID)])),
("changed", nsid, params(&[("repo", DID), ("state", "open")])),
("added", nsid, params(&[("subject", DID)])),
(
"changed",
nsid,
params(&[("subject", DID), ("state", "open")])
),
])
);
assert!(
lines(&old, &new)
.iter()
.all(|line| line.any == ["limit", "offset", "order"]),
"a request differing only in params the map ignores reads the same answer"
);
let rows_gone = social(vec![issues("social/empty.json", &[])]);
let back = lines(&new, &rows_gone);
assert!(
back.iter()
.all(|line| line.op == "removed" && line.new.is_none()),
"a key falling back to the default reads as removed"
);
let changed_default = lines(&rows_gone, &social(vec![issues("social/empty2.json", &[])]));
assert_eq!(changed_default.len(), 1);
assert!(changed_default[0].default);
assert_eq!(
changed_default[0].old.as_deref(),
Some("_atproto/social/empty.json")
);
}
#[test]
fn bucket_keyed_maps_list_their_arguments_as_any() {
let token = crate::plan::repo_token(DID);
let page = MapBlock {
source: "$arg_limit|$arg_reverse".into(),
target: "$ks_atp_lrpage".into(),
default: String::new(),
rows: vec![("100|".into(), "100f".into())],
};
let list = |file: &str| MapBlock {
source: "$ks_atp_repo|$ks_atp_lrpage|$arg_collection|$arg_cursor".into(),
target: "$ks_atp_lr".into(),
default: String::new(),
rows: vec![(
format!("{token}|100f|org.tangled.track.ticket|"),
file.into(),
)],
};
let old = social(vec![
page_clone(&page),
list(&format!("{token}/lists/a.json")),
]);
let new = social(vec![
page_clone(&page),
list(&format!("{token}/lists/b.json")),
]);
let got = lines(&old, &new);
assert_eq!(
ops(&got),
set([(
"changed",
"com.atproto.repo.listRecords",
params(&[("repo", DID), ("collection", "org.tangled.track.ticket")])
)])
);
assert_eq!(got[0].any, ["limit", "reverse"]);
assert!(lines(&new, &new).is_empty());
}
fn page_clone(map: &MapBlock) -> MapBlock {
MapBlock {
source: map.source.clone(),
target: map.target.clone(),
default: map.default.clone(),
rows: map.rows.clone(),
}
}
fn entry(did: &str, name: &str, refs: &[(&str, &str)]) -> crate::manifest::RepoEntry {
crate::manifest::RepoEntry {
repo_did: did.into(),
owner: "did:plc:owner".into(),
rkey: name.into(),
name: Some(name.into()),
head: Some("refs/heads/main".into()),
public_refs: refs
.iter()
.map(|(n, o)| (n.to_string(), o.to_string()))
.collect(),
cob_refs: Vec::new(),
object_format: "sha1".into(),
fingerprint: refs.iter().map(|(_, o)| *o).collect(),
git_export_hash: None,
release_id: None,
fetch_bases: Vec::new(),
shallow_branches: Vec::new(),
shallow_head: None,
moved_wants: Vec::new(),
}
}
fn manifest(repos: Vec<crate::manifest::RepoEntry>) -> crate::manifest::GenerationManifest {
serde_json::from_value(serde_json::json!({
"generation_id": "", "generator_profile": "", "knot_did": "", "hostname": "", "owner": "",
"static_version": "", "profile": {}, "repos": repos,
}))
.unwrap()
}
#[test]
fn summary_reads_pushes_cobs_renames_and_removals_off_the_changes() {
let old = manifest(vec![
entry(DID, "kept", &[("refs/heads/main", ONE)]),
entry("did:plc:renamed", "before", &[]),
entry("did:plc:gone", "gone", &[]),
]);
let new = manifest(vec![
entry(
DID,
"kept",
&[("refs/heads/main", TWO), ("refs/heads/topic", ONE)],
),
entry("did:plc:renamed", "after", &[]),
entry("did:plc:added", "added", &[]),
]);
let ticket = Line {
op: "added",
nsid: Some(diff::RECORD),
params: params(&[
("repo", DID),
("collection", "org.tangled.track.ticket"),
("rkey", "3k"),
]),
any: Vec::new(),
default: false,
old: None,
new: Some("x".into()),
};
let handles = BTreeMap::new();
let summary = super::summarize(Some(&old), &new, &[ticket], (&handles, &handles));
assert_eq!(summary.trigger["kind"], "push");
assert_eq!(summary.removed, ["did:plc:gone"]);
let by_did: BTreeMap<&str, &serde_json::Value> = summary
.repos
.iter()
.map(|repo| (repo["did"].as_str().unwrap(), repo))
.collect();
let kept = by_did[DID];
assert_eq!(kept["change"], "updated");
assert_eq!(
kept["refs"],
serde_json::json!([
{ "ref": "refs/heads/main", "old": ONE, "new": TWO },
{ "ref": "refs/heads/topic", "old": null, "new": ONE },
])
);
assert_eq!(
kept["cobs"],
serde_json::json!([{ "collection": "org.tangled.track.ticket", "rkey": "3k", "op": "create" }])
);
assert_eq!(by_did["did:plc:renamed"]["change"], "renamed");
assert_eq!(by_did["did:plc:renamed"]["renamed_from"]["name"], "before");
assert_eq!(by_did["did:plc:added"]["change"], "added");
let handle_moved = BTreeMap::from([("did:plc:owner".to_string(), "new.example".to_string())]);
let quiet = super::summarize(Some(&new), &new, &[], (&handles, &handle_moved));
assert_eq!(quiet.trigger["kind"], "identity");
assert!(quiet.repos.iter().all(|repo| repo["change"] == "renamed"));
assert!(
quiet
.repos
.iter()
.all(|repo| repo["owner_handle"] == "new.example")
);
}