Parallel agent batch. All five tasks delivered functional + tested.
NOT deployed — user is in live conversation with the bot.
## Crates added (2 new)
### kei-contacts-google (466 LOC, 5 tests)
Thin Google People API client. Takes pre-acquired access_token from
kei-auth-google's OAuth flow; calls /v1/people/me/connections?personFields=...,
parses 200-entry first page (TODO: pagination via nextPageToken), maps
to kei_social_store::Person. Errors: Http / Auth(401) / Parse.
### kei-contacts-apple (593 LOC, 7 tests + 1 doc-test)
CardDAV client for iCloud Contacts using Basic Auth (Apple ID +
app-specific password). Sends REPORT with addressbook-query XML body,
parses multistatus → embedded vCards → AppleContact. Tiny vCard
parser (~150 LOC) handles FN/N/EMAIL/TEL/ORG/NOTE/UID, single-line
only (no line-folding for MVP). Discovery (PROPFIND .well-known/carddav
→ principal → addressbook-home-set) deferred — user supplies
addressbook URL via with_addressbook_url().
Both crates registered in workspace members.
## kei-buddy crate additions
### src/topic_classify.rs (116 LOC, 3 tests)
Free fn classify_and_store_topic(extractor, topics, chat_id, text)
called from process_text when state == OnboardState::Ready. Builds
classifier prompt → LLM → parses {slug, title} → validates slug
shape (kebab-case, ascii) → Topics::add_topic + add_digest. All
failure paths log + return; conversation never blocks.
### src/tick.rs (188 LOC, 3 integration tests) + src/bin/kei-buddy-tick.rs (67 LOC)
Second binary. Oneshot CLI for systemd timer: walks all known
chat_ids in BuddyStore → lists topics → searches recent chat
messages per topic (configurable window/limit) → LLM digest →
Topics::add_digest. Outputs JSON TickReport to stdout. Env-driven
config. NoOpExtractor fallback when no LLM creds (graceful degradation).
### src/commands.rs (146 LOC) + src/command_exec.rs (111 LOC, 7 tests)
Slash-commands intercepted BEFORE handle_step in process_text:
/whois <name> contacts.search_contacts + common_connections for hits
/find <q> chat_log.search scoped to chat_id
/topics topics.list_topics
/contacts contacts.search_contacts("", 10)
/help static usage text (Russian)
If command parsed, response built from stores, sent, logged to
chat_log — FSM skipped for that turn.
### src/serve_runner.rs (69 LOC) — refactor
run_serve + start_listener + init_tracing extracted out of serve.rs
to bring serve.rs back to 189 LOC (was 248 after previous wave).
### Wiring
BuddyContext gains `contacts: Arc<Contacts>` and `topics: Arc<Topics>`.
ServeConfig gains contacts_db_path + topics_db_path. Binary reads
KEI_BUDDY_CONTACTS_DB_PATH + KEI_BUDDY_TOPICS_DB_PATH env (defaults
./kei-buddy-contacts.db, ./kei-buddy-topics.db). cmd_migrate applies
schema for all three side-stores (chat_log + contacts + topics).
## Verify-before-commit (RULE 0.13 §)
* cargo check -p kei-buddy (default + extractor-openai): PASS
* cargo test -p kei-buddy --lib: 41 passed / 0 failed (was 31)
* cargo test -p kei-buddy --tests: 3 passed (tick integration)
* cargo build -p kei-buddy --features extractor-openai: PASS
(builds both kei-buddy + kei-buddy-tick binaries)
* cargo check -p kei-contacts-google: PASS (5 tests)
* cargo check -p kei-contacts-apple: PASS (7 + 1 doc)
* cargo check --workspace: PASS
## STATUS-TRUTH from all 5 agents: shipped=functional, behaviour-verified=yes
## Follow-up (deferred, non-blocking)
* Google People API pagination (nextPageToken loop) — first 200 only
* CardDAV auto-discovery (PROPFIND .well-known/carddav)
* vCard line-folding (RFC 6350 §3.2)
* Wire kei-contacts-google + kei-contacts-apple → Contacts.add_contact
sync command (no glue yet)
* systemd timer file for kei-buddy-tick (not shipped here — config only)
108 lines
3.7 KiB
Rust
108 lines
3.7 KiB
Rust
//! Integration tests for kei-conflict-scan.
|
|
|
|
use std::fs;
|
|
use std::path::{Path, PathBuf};
|
|
use tempfile::TempDir;
|
|
|
|
fn bin() -> PathBuf {
|
|
PathBuf::from(env!("CARGO_BIN_EXE_kei-conflict-scan"))
|
|
}
|
|
|
|
fn write(root: &Path, rel: &str, body: &str) {
|
|
let full = root.join(rel);
|
|
if let Some(parent) = full.parent() {
|
|
fs::create_dir_all(parent).unwrap();
|
|
}
|
|
fs::write(&full, body).unwrap();
|
|
}
|
|
|
|
fn run(root: &Path, extra: &[&str]) -> serde_json::Value {
|
|
let mut args = vec!["--path".to_string(), root.to_string_lossy().into_owned()];
|
|
args.extend(extra.iter().map(|s| s.to_string()));
|
|
let out = std::process::Command::new(bin()).args(&args).output().unwrap();
|
|
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
|
|
serde_json::from_slice(&out.stdout).unwrap()
|
|
}
|
|
|
|
#[test]
|
|
fn empty_tree_is_clean() {
|
|
let tmp = TempDir::new().unwrap();
|
|
let v = run(tmp.path(), &[]);
|
|
assert_eq!(v["hit_count"], 0);
|
|
}
|
|
|
|
#[test]
|
|
fn contradictory_rules_flagged() {
|
|
let tmp = TempDir::new().unwrap();
|
|
write(tmp.path(), "rules/a.md", "Never: push to github\n");
|
|
write(tmp.path(), "rules/b.md", "Always: push to github\n");
|
|
let v = run(tmp.path(), &["--only", "rules"]);
|
|
assert!(v["hit_count"].as_u64().unwrap() >= 1, "{}", v);
|
|
assert_eq!(v["conflicts"][0]["category"], "rules");
|
|
}
|
|
|
|
#[test]
|
|
fn duplicate_blocks_flagged() {
|
|
let tmp = TempDir::new().unwrap();
|
|
let body =
|
|
"this is a long shared paragraph with many identical words over and over again repeated";
|
|
write(tmp.path(), "_blocks/a.md", body);
|
|
write(tmp.path(), "_blocks/b.md", body);
|
|
let v = run(tmp.path(), &["--only", "blocks"]);
|
|
assert!(v["hit_count"].as_u64().unwrap() >= 1, "{}", v);
|
|
assert_eq!(v["conflicts"][0]["category"], "blocks");
|
|
}
|
|
|
|
#[test]
|
|
fn orphan_wikilinks_flagged() {
|
|
let tmp = TempDir::new().unwrap();
|
|
write(tmp.path(), "docs/a.md", "see [[nonexistent-target]] for details");
|
|
let v = run(tmp.path(), &["--only", "orphans"]);
|
|
assert!(v["hit_count"].as_u64().unwrap() >= 1, "{}", v);
|
|
assert_eq!(v["conflicts"][0]["category"], "orphans");
|
|
}
|
|
|
|
#[test]
|
|
fn cross_repo_wikilink_not_flagged() {
|
|
// `[[../../../rules/X]]` escapes the scan root — engine cannot validate,
|
|
// must not false-positive.
|
|
let tmp = TempDir::new().unwrap();
|
|
write(tmp.path(), "memory/MEMORY.md", "see [[../../../rules/recurrence-escalate]]");
|
|
let v = run(tmp.path(), &["--only", "orphans"]);
|
|
assert_eq!(v["hit_count"].as_u64().unwrap(), 0, "{}", v);
|
|
}
|
|
|
|
#[test]
|
|
fn path_prefixed_wikilink_matches_basename() {
|
|
// `[[chatlogs/X/Y]]` should resolve when `Y.md` exists anywhere in the tree.
|
|
let tmp = TempDir::new().unwrap();
|
|
write(tmp.path(), "chatlogs/X/Y.md", "target body");
|
|
write(tmp.path(), "memory/index.md", "ref to [[chatlogs/X/Y]]");
|
|
let v = run(tmp.path(), &["--only", "orphans"]);
|
|
assert_eq!(v["hit_count"].as_u64().unwrap(), 0, "{}", v);
|
|
}
|
|
|
|
#[test]
|
|
fn oversize_file_flagged() {
|
|
let tmp = TempDir::new().unwrap();
|
|
let mut body = String::new();
|
|
for _ in 0..250 {
|
|
body.push_str("line\n");
|
|
}
|
|
write(tmp.path(), "src/big.rs", &body);
|
|
let v = run(tmp.path(), &["--only", "cp"]);
|
|
assert!(v["hit_count"].as_u64().unwrap() >= 1, "{}", v);
|
|
assert_eq!(v["conflicts"][0]["category"], "cp");
|
|
}
|
|
|
|
#[test]
|
|
fn json_schema_has_required_fields() {
|
|
let tmp = TempDir::new().unwrap();
|
|
write(tmp.path(), "rules/a.md", "Never: do X\n");
|
|
write(tmp.path(), "rules/b.md", "Always: do X\n");
|
|
let v = run(tmp.path(), &["--only", "rules"]);
|
|
let c = &v["conflicts"][0];
|
|
for k in ["category", "severity", "files", "evidence", "suggested_fix", "auto_resolvable"] {
|
|
assert!(c.get(k).is_some(), "missing field {}: {}", k, c);
|
|
}
|
|
}
|