use std::collections::{BTreeMap, BTreeSet, HashMap};
use std::fmt::Write as _;
use std::io::Read;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use ignore::{IncrementalIgnore, WalkBuilder};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use crate::util::UnwrapPoison;
use crate::{Role, Workspace};
#[derive(Debug, Clone, Serialize, Deserialize)]
struct FileState {
size: u64,
mtime_ns: i64,
digest: Option<u64>,
}
type Snapshot = BTreeMap<String, FileState>;
const HASH_MAX_BYTES: u64 = 64 * 1024 * 1024;
const READ_BUFFER_BYTES: usize = 64 * 1024;
const STAMP_UNKNOWN: i64 = i64::MIN;
fn digest_of(path: &Path) -> Option<u64> {
let mut file = std::fs::File::open(path).ok()?;
let mut hasher = Sha256::new();
let mut buf = vec![0u8; READ_BUFFER_BYTES];
loop {
let n = file.read(&mut buf).ok()?;
if n == 0 {
break;
}
hasher.update(&buf[..n]);
}
let digest = hasher.finalize();
let mut head = [0u8; 8];
head.copy_from_slice(&digest[..8]);
Some(u64::from_be_bytes(head))
}
fn mtime_ns(meta: &std::fs::Metadata) -> i64 {
meta.modified()
.ok()
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.and_then(|d| i64::try_from(d.as_nanos()).ok())
.unwrap_or(STAMP_UNKNOWN)
}
fn is_skipped(entry: &ignore::DirEntry, owner_folder: bool) -> bool {
if entry.depth() == 0 {
return false; }
let Some(name) = entry.file_name().to_str() else {
return false;
};
if name == ".DS_Store" {
return true;
}
if matches!(name, ".git" | ".compaction") && entry.file_type().is_some_and(|t| t.is_dir()) {
return true;
}
if entry.depth() == 1 {
if name == "MEMORY.md" {
return true;
}
if entry.file_type().is_some_and(|t| t.is_dir()) {
if matches!(name, "generated" | "uploads" | "notes" | "archive") {
return true;
}
if name == "shared" && owner_folder {
return true;
}
}
}
false
}
const MAX_PICTURED_FOLDERS: usize = 40;
#[derive(Clone)]
struct Rules {
root: PathBuf,
state: Arc<Mutex<RulesState>>,
}
#[derive(Default)]
struct RulesState {
matchers: HashMap<PathBuf, IncrementalIgnore>,
governing: HashMap<PathBuf, Option<PathBuf>>,
tracked: HashMap<PathBuf, Option<BTreeSet<String>>>,
}
impl Rules {
fn new(root: &Path) -> Self {
Self {
root: root.to_path_buf(),
state: Arc::new(Mutex::new(RulesState::default())),
}
}
fn hides(&self, entry: &ignore::DirEntry) -> bool {
let Ok(rel) = entry.path().strip_prefix(&self.root) else {
return false;
};
if rel.as_os_str().is_empty() {
return false;
}
self.hidden(rel, entry.file_type().is_some_and(|t| t.is_dir()))
}
fn hidden(&self, rel: &Path, is_dir: bool) -> bool {
self.state
.lock()
.unwrap_poison()
.hidden(&self.root, rel, is_dir)
}
}
impl RulesState {
fn hidden(&mut self, root: &Path, rel: &Path, is_dir: bool) -> bool {
if self.excluded(root, rel, is_dir) && self.tracked(root, rel, is_dir) == Some(false) {
return true;
}
let Some(governing) = self.repository(root, rel) else {
return false;
};
let mut above = rel.parent();
while let Some(dir) = above.filter(|dir| !dir.as_os_str().is_empty()) {
if self.repository(root, dir).as_deref() != Some(governing.as_path())
&& self.excluded(root, dir, true)
&& self.tracked(root, dir, true) == Some(false)
{
return true;
}
above = dir.parent();
}
false
}
fn excluded(&mut self, root: &Path, rel: &Path, is_dir: bool) -> bool {
let Some(repo) = self.repository(root, rel) else {
return false;
};
let in_repo = rel.strip_prefix(&repo).unwrap_or(rel);
self.matchers
.entry(repo.clone())
.or_insert_with(|| build_matcher(&root.join(&repo)))
.matched(in_repo, is_dir)
.is_ignore()
}
fn tracked(&mut self, root: &Path, rel: &Path, is_dir: bool) -> Option<bool> {
let repo = self.repository(root, rel)?;
let tracked = self
.tracked
.entry(repo.clone())
.or_insert_with(|| load_tracked(&root.join(&repo)))
.as_ref()?;
let key = slash_path(rel.strip_prefix(&repo).unwrap_or(rel))?;
if tracked.contains(&key) {
return Some(true);
}
if !is_dir {
return Some(false);
}
let prefix = format!("{key}/");
Some(
tracked
.range(prefix.clone()..)
.next()
.is_some_and(|path| path.starts_with(&prefix)),
)
}
fn repository(&mut self, root: &Path, rel: &Path) -> Option<PathBuf> {
let dir = rel.parent().unwrap_or(Path::new("")).to_path_buf();
if let Some(known) = self.governing.get(&dir) {
return known.clone();
}
let found = if crate::git::commands::is_git_repo(&root.join(&dir)) {
Some(dir.clone())
} else if dir.as_os_str().is_empty() {
None
} else {
self.repository(root, &dir)
};
self.governing.insert(dir, found.clone());
found
}
}
fn build_matcher(repo: &Path) -> IncrementalIgnore {
let mut builder = WalkBuilder::new(repo);
builder
.standard_filters(false)
.git_ignore(true)
.git_exclude(true);
builder
.build_matchers()
.pop()
.expect("a walk root always yields its matcher")
}
fn load_tracked(repo: &Path) -> Option<BTreeSet<String>> {
let Some(tracked) = crate::git::commands::tracked_paths(repo) else {
tracing::debug!(
repo = %repo.display(),
"personal-workspace repository index unreadable — its ignored paths stay visible"
);
return None;
};
Some(tracked.into_iter().collect())
}
fn slash_path(path: &Path) -> Option<String> {
let mut out = String::new();
for component in path.components() {
if !out.is_empty() {
out.push('/');
}
out.push_str(component.as_os_str().to_str()?);
}
Some(out)
}
pub(crate) fn personal_folder_picture(root: &Path) -> Option<String> {
if let Err(e) = std::fs::read_dir(root) {
if e.kind() == std::io::ErrorKind::NotFound {
return None; }
tracing::warn!(root = %root.display(), error = %e, "personal-workspace folder unreadable");
return None;
}
let owner_folder = is_owner_personal_folder(root);
let rules = Rules::new(root);
let mut folders: BTreeMap<String, usize> = BTreeMap::new();
let mut root_files = 0usize;
for result in WalkBuilder::new(root)
.standard_filters(false)
.follow_links(false)
.filter_entry(move |entry| !is_skipped(entry, owner_folder) && !rules.hides(entry))
.build()
{
let entry = match result {
Ok(entry) => entry,
Err(e) => {
tracing::warn!(error = %e, "personal-workspace walk error");
continue;
}
};
if entry.depth() == 0 {
continue; }
let path = entry.path();
let Ok(rel) = path.strip_prefix(root) else {
continue;
};
let Some(rel) = rel.to_str() else {
continue; };
let Ok(meta) = std::fs::metadata(path).or_else(|_| std::fs::symlink_metadata(path)) else {
continue; };
if meta.is_dir() {
if entry.depth() == 1 && entry.file_type().is_some_and(|t| t.is_dir()) {
folders.entry(rel.to_string()).or_default();
}
continue;
}
match rel.split_once(std::path::MAIN_SEPARATOR) {
Some((folder, _)) => *folders.entry(folder.to_string()).or_default() += 1,
None => root_files += 1,
}
}
if folders.is_empty() && root_files == 0 {
return None; }
Some(render_personal_folder_lines(&folders, root_files))
}
fn counted(n: usize, noun: &str) -> String {
if n == 1 {
format!("1 {noun}")
} else {
format!("{n} {noun}s")
}
}
fn render_personal_folder_lines(folders: &BTreeMap<String, usize>, root_files: usize) -> String {
let mut out = String::new();
for (name, count) in folders.iter().take(MAX_PICTURED_FOLDERS) {
let _ = writeln!(out, "- {name}/ — {}", counted(*count, "file"));
}
let left = folders.len().saturating_sub(MAX_PICTURED_FOLDERS);
if left > 0 {
let _ = writeln!(out, "…and {} not named", counted(left, "folder"));
}
let _ = write!(
out,
"{} directly in the folder",
counted(root_files, "file")
);
out
}
fn under_userspaces(path: &Path) -> Option<PathBuf> {
let root = crate::stored_workspace_path(&crate::users::userspaces_root());
crate::stored_workspace_path(path)
.strip_prefix(root)
.ok()
.map(Path::to_path_buf)
}
fn personal_account_in(path: &Path) -> Option<String> {
under_userspaces(path)?
.components()
.next()?
.as_os_str()
.to_str()
.map(str::to_string)
}
fn is_owner_personal_folder(root: &Path) -> bool {
let Ok(rel) = root.strip_prefix(crate::users::userspaces_root()) else {
return false;
};
let mut parts = rel.components();
let account = parts.next().and_then(|c| c.as_os_str().to_str());
parts.next().is_none() && account.is_some_and(crate::users::is_admin_name)
}
fn error_path(err: &ignore::Error) -> Option<&Path> {
match err {
ignore::Error::WithPath { path, .. } => Some(path),
ignore::Error::WithLineNumber { err, .. } | ignore::Error::WithDepth { err, .. } => {
error_path(err)
}
_ => None,
}
}
fn freeze(previous: Option<&Snapshot>, rel: &str, out: &mut Snapshot) {
let Some(previous) = previous else {
return;
};
if let Some(state) = previous.get(rel) {
out.insert(rel.to_string(), state.clone());
}
for (path, state) in previous.range(format!("{rel}/")..format!("{rel}0")) {
out.insert(path.clone(), state.clone());
}
}
fn personal_user_of(workspace: &Workspace) -> Option<String> {
personal_account_in(Path::new(&workspace.path))
.or_else(|| crate::users::personal_user_name(&workspace.name).map(str::to_string))
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum Scan {
Report,
Refresh,
Whole,
}
impl Scan {
fn stores(self) -> bool {
!matches!(self, Self::Report)
}
}
fn scan(root: &Path, previous: Option<&Snapshot>, mode: Scan, rules: &Rules) -> Option<Snapshot> {
let stores = mode.stores();
if std::fs::read_dir(root).is_err() {
tracing::warn!(root = %root.display(), "personal-workspace folder unreadable");
return None;
}
let mut out = Snapshot::new();
let whole_walk = mode == Scan::Whole;
let owner_folder = is_owner_personal_folder(root);
let walk_rules = rules.clone();
let stop = crate::shutdown::shutdown_token();
for result in WalkBuilder::new(root)
.standard_filters(false)
.follow_links(false)
.filter_entry(move |entry| !is_skipped(entry, owner_folder) && !walk_rules.hides(entry))
.build()
{
if whole_walk && stop.is_cancelled() {
return None;
}
let entry = match result {
Ok(entry) => entry,
Err(e) => {
if e.depth() == Some(0) {
return None;
}
let Some(rel) = error_path(&e)
.and_then(|path| path.strip_prefix(root).ok())
.and_then(Path::to_str)
else {
tracing::warn!(error = %e, "personal-workspace walk error without a path");
return None;
};
tracing::warn!(
path = %rel,
error = %e,
"personal-workspace walk error — freezing its previous state"
);
freeze(previous, rel, &mut out);
continue;
}
};
if entry.depth() == 0 {
continue;
}
let path = entry.path();
let Ok(rel) = path.strip_prefix(root) else {
continue;
};
let Some(rel) = rel.to_str() else {
continue; };
let meta = std::fs::metadata(path).or_else(|_| std::fs::symlink_metadata(path));
let Ok(meta) = meta else {
tracing::warn!(
path = %path.display(),
"personal-workspace entry metadata unreadable — freezing its previous state"
);
freeze(previous, rel, &mut out);
continue;
};
if meta.is_dir() {
continue; }
let size = meta.len();
let mtime = mtime_ns(&meta);
let digest = if meta.is_file() {
match previous.and_then(|prev| prev.get(rel)) {
Some(prev)
if prev.size == size && prev.mtime_ns == mtime && mtime != STAMP_UNKNOWN =>
{
prev.digest
}
None if !stores => None,
Some(prev) if !stores && prev.size != size => None,
_ if size > HASH_MAX_BYTES => None,
_ => digest_of(path),
}
} else {
None
};
out.insert(
rel.to_string(),
FileState {
size,
mtime_ns: mtime,
digest,
},
);
}
Some(out)
}
#[derive(Debug, Default)]
struct Changes {
added: Vec<String>,
changed: Vec<String>,
removed: Vec<String>,
}
fn diff(previous: &Snapshot, current: &Snapshot, rules: &Rules) -> Changes {
let mut changes = Changes::default();
for (path, cur) in current {
match previous.get(path) {
None => changes.added.push(path.clone()),
Some(prev) => {
let same_content = matches!(
(prev.digest, cur.digest),
(Some(before), Some(after)) if before == after
);
let moved = prev.mtime_ns != cur.mtime_ns
|| prev.mtime_ns == STAMP_UNKNOWN
|| cur.mtime_ns == STAMP_UNKNOWN;
if prev.size != cur.size || (moved && !same_content) {
changes.changed.push(path.clone());
}
}
}
}
for path in previous.keys() {
if !current.contains_key(path) && !rules.hidden(Path::new(path), false) {
changes.removed.push(path.clone());
}
}
changes
}
const MAX_LISTED_ENTRIES: usize = 40;
const MAX_BLOCK_BYTES: usize = 2 * 1024;
fn render(changes: &Changes) -> Option<String> {
let groups = [
("added: ", &changes.added),
("changed: ", &changes.changed),
("removed: ", &changes.removed),
];
let total: usize = groups.iter().map(|(_, list)| list.len()).sum();
if total == 0 {
return None;
}
let mut lines = String::new();
let mut shown = 0usize;
let mut capped = false;
for (label, list) in groups {
let mut names: Vec<&str> = Vec::new();
let mut cost = lines.len();
for name in list {
if shown >= MAX_LISTED_ENTRIES {
capped = true;
break;
}
let add = name.len() + if names.is_empty() { label.len() } else { 2 };
if cost + add > MAX_BLOCK_BYTES {
capped = true;
break;
}
cost += add;
names.push(name);
shown += 1;
}
if !names.is_empty() {
let _ = writeln!(lines, "{label}{}", names.join(", "));
}
if capped {
break;
}
}
if total > shown {
let _ = write!(lines, "…and {} more", total - shown);
}
let asset = crate::prompt::load_prompt("context/personal_changes.md");
Some(crate::prompt::substitute(
&asset,
&[("{{changes}}", lines.trim_end())],
))
}
pub(crate) async fn prepend_changes(msg: &str, workspace: &Workspace, role: &Role) -> String {
if !matches!(role, Role::Assistant) {
return msg.to_string();
}
let Some(user_name) = personal_account_of(workspace) else {
return msg.to_string();
};
if ESTABLISHING.lock().unwrap_poison().contains_key(&user_name) {
return msg.to_string();
}
let agent_id = crate::session::assistant_agent_id(&user_name);
let Some(Some(stored)) = crate::session::store()
.personal_changes_baseline(&agent_id)
.await
else {
return msg.to_string();
};
let root = crate::users::personal_workspace_path(&user_name);
let scanned = tokio::task::spawn_blocking(move || {
let previous: Snapshot = match serde_json::from_str(&stored) {
Ok(previous) => previous,
Err(e) => {
tracing::warn!(%e, "Unparseable personal-changes baseline — skipping the report");
return None;
}
};
let rules = Rules::new(&root);
let current = scan(&root, Some(&previous), Scan::Report, &rules)?;
Some(diff(&previous, ¤t, &rules))
})
.await;
let Some(Some(changes)) = scanned.ok() else {
return msg.to_string();
};
match render(&changes) {
Some(block) => format!("{block}\n\n{msg}"),
None => msg.to_string(),
}
}
pub(crate) async fn refresh_after_turn(role: &Role, workspace: &Workspace, recorded: bool) {
if !recorded || !matches!(role, Role::Assistant) {
return;
}
let Some(user_name) = personal_account_of(workspace) else {
return;
};
refresh(&user_name).await;
}
fn personal_account_of(workspace: &Workspace) -> Option<String> {
let user_name = personal_user_of(workspace)?;
crate::users::is_valid_personal_user_name(&user_name).then_some(user_name)
}
async fn refresh(user_name: &str) {
if request_another_walk(user_name) {
return;
}
let agent_id = crate::session::assistant_agent_id(user_name);
let Some(stored) = crate::session::store()
.personal_changes_baseline(&agent_id)
.await
else {
return;
};
if stored.is_none() {
establish(user_name, agent_id);
return;
}
match observe(
crate::users::personal_workspace_path(user_name),
stored,
Scan::Refresh,
)
.await
{
Observation::Fresh(json) => store_baseline(user_name, &agent_id, &json).await,
Observation::Settled => {}
Observation::Unusable => establish(user_name, agent_id),
}
}
fn establish(user_name: &str, agent_id: String) {
let user_name = user_name.to_string();
let root = crate::users::personal_workspace_path(&user_name);
let establishing = {
let mut establishing = ESTABLISHING.lock().unwrap_poison();
if let Some(in_flight) = establishing.get_mut(&user_name) {
in_flight.again = true;
return;
}
let owner = NEXT_OWNER.fetch_add(1, Ordering::Relaxed);
establishing.insert(
user_name.clone(),
InFlight {
again: false,
owner,
},
);
Establishing {
user_name: user_name.clone(),
owner,
}
};
tokio::spawn(async move {
let owner = establishing.owner;
let Some(mut previous) = crate::session::store()
.personal_changes_baseline(&agent_id)
.await
else {
return; };
loop {
if let Observation::Fresh(json) =
observe(root.clone(), previous.clone(), Scan::Whole).await
{
store_baseline(&user_name, &agent_id, &json).await;
previous = Some(json);
}
if !take_again_or_finish(&user_name, owner) {
return;
}
}
});
}
struct InFlight {
again: bool,
owner: u64,
}
static ESTABLISHING: std::sync::Mutex<BTreeMap<String, InFlight>> =
std::sync::Mutex::new(BTreeMap::new());
static NEXT_OWNER: AtomicU64 = AtomicU64::new(0);
fn request_another_walk(user_name: &str) -> bool {
let mut establishing = ESTABLISHING.lock().unwrap_poison();
match establishing.get_mut(user_name) {
Some(in_flight) => {
in_flight.again = true;
true
}
None => false,
}
}
fn take_again_or_finish(user_name: &str, owner: u64) -> bool {
let mut establishing = ESTABLISHING.lock().unwrap_poison();
match establishing.get_mut(user_name) {
Some(in_flight) if in_flight.owner != owner => false,
Some(in_flight) if in_flight.again => {
in_flight.again = false;
true
}
Some(_) => {
establishing.remove(user_name);
false
}
None => false,
}
}
struct Establishing {
user_name: String,
owner: u64,
}
impl Drop for Establishing {
fn drop(&mut self) {
let mut establishing = ESTABLISHING.lock().unwrap_poison();
if establishing
.get(&self.user_name)
.is_some_and(|in_flight| in_flight.owner == self.owner)
{
establishing.remove(&self.user_name);
}
}
}
enum Observation {
Fresh(String),
Settled,
Unusable,
}
async fn observe(root: PathBuf, stored: Option<String>, mode: Scan) -> Observation {
tokio::task::spawn_blocking(move || {
let previous: Snapshot = match stored.as_deref() {
None => Snapshot::new(),
Some(stored) => match serde_json::from_str(stored) {
Ok(previous) => previous,
Err(e) => {
tracing::warn!(%e, "Unreadable personal-changes baseline");
if mode != Scan::Whole {
return Observation::Unusable;
}
Snapshot::new()
}
},
};
let Some(current) = scan(&root, Some(&previous), mode, &Rules::new(&root)) else {
return Observation::Settled;
};
let json = match serde_json::to_string(¤t) {
Ok(json) => json,
Err(e) => {
tracing::warn!(%e, "Failed to serialize personal-changes baseline");
return Observation::Settled;
}
};
if stored.as_deref() == Some(json.as_str()) {
Observation::Settled
} else {
Observation::Fresh(json)
}
})
.await
.unwrap_or(Observation::Settled)
}
async fn store_baseline(user_name: &str, agent_id: &str, json: &str) {
if let Err(e) = crate::session::store()
.update_personal_changes(agent_id, json)
.await
{
tracing::warn!(
user = %user_name,
error = %e,
"Failed to persist personal-changes baseline"
);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::db::params;
use crate::util::test::{init_git_repo, init_temp_repo};
fn pictured_folders(picture: &str) -> (BTreeMap<String, usize>, usize) {
let count_of = |rest: &str| {
rest.split_once(' ')
.expect("a count and a noun")
.0
.parse::<usize>()
.expect("a number")
};
let mut folders = BTreeMap::new();
let mut direct = 0;
for line in picture.lines() {
if let Some(rest) = line.strip_prefix("- ") {
let (name, count) = rest.split_once("/ — ").expect("a folder line");
folders.insert(name.to_string(), count_of(count));
} else {
direct = count_of(
line.split_once(" directly in the folder")
.expect("the direct-file line")
.0,
);
}
}
(folders, direct)
}
async fn wait_for_baseline(user: &str) -> Option<String> {
let agent_id = crate::session::assistant_agent_id(user);
for _ in 0..200 {
if ESTABLISHING.lock().unwrap_poison().contains_key(user) {
tokio::time::sleep(std::time::Duration::from_millis(25)).await;
continue;
}
if let Some(Some(json)) = crate::session::store()
.personal_changes_baseline(&agent_id)
.await
{
return Some(json);
}
refresh(user).await;
tokio::time::sleep(std::time::Duration::from_millis(25)).await;
}
None
}
#[test]
fn scan_diff_classifies_added_changed_removed() {
let tmp = tempfile::TempDir::new().unwrap();
let root = tmp.path();
for (name, body) in [
("a.txt", "aaa"),
("b.txt", "bbb"),
("c.txt", "ccc"),
("d.txt", "ddd"),
] {
std::fs::write(root.join(name), body).unwrap();
}
let before = scan(root, None, Scan::Refresh, &Rules::new(root)).unwrap();
std::thread::sleep(std::time::Duration::from_millis(20));
std::fs::write(root.join("a.txt"), "aaa-edited").unwrap(); std::fs::write(root.join("b.txt"), "bbb").unwrap(); std::fs::write(root.join("e.txt"), "eee").unwrap(); std::fs::rename(root.join("c.txt"), root.join("c2.txt")).unwrap(); std::fs::remove_file(root.join("d.txt")).unwrap();
let after = scan(root, Some(&before), Scan::Refresh, &Rules::new(root)).unwrap();
let changes = diff(&before, &after, &Rules::new(root));
assert_eq!(
changes.added,
vec!["c2.txt".to_string(), "e.txt".to_string()]
);
assert_eq!(changes.changed, vec!["a.txt".to_string()]);
assert_eq!(
changes.removed,
vec!["c.txt".to_string(), "d.txt".to_string()]
);
let report = scan(root, Some(&before), Scan::Report, &Rules::new(root)).unwrap();
let report_changes = diff(&before, &report, &Rules::new(root));
assert_eq!(report_changes.changed, vec!["a.txt".to_string()]);
assert_eq!(
report["a.txt"].digest, None,
"a size change must not cost a read on the report path"
);
assert!(
report["b.txt"].digest.is_some(),
"an identical-size rewrite must still be read"
);
assert!(
scan(
&root.join("gone"),
None,
Scan::Refresh,
&Rules::new(&root.join("gone"))
)
.is_none()
);
}
#[test]
fn scan_reads_a_file_whose_stamp_the_platform_will_not_report() {
let tmp = tempfile::TempDir::new().unwrap();
let root = tmp.path();
let file = root.join("old.txt");
std::fs::write(&file, "one").unwrap();
let before_epoch = std::time::UNIX_EPOCH
.checked_sub(std::time::Duration::from_secs(60))
.unwrap();
let handle = std::fs::OpenOptions::new().write(true).open(&file).unwrap();
if handle.set_modified(before_epoch).is_err() {
return;
}
let before = scan(root, None, Scan::Refresh, &Rules::new(root)).unwrap();
if before["old.txt"].mtime_ns != STAMP_UNKNOWN {
return; }
assert!(before["old.txt"].digest.is_some());
std::fs::write(&file, "two").unwrap();
handle.set_modified(before_epoch).unwrap();
let after = scan(root, Some(&before), Scan::Refresh, &Rules::new(root)).unwrap();
assert_eq!(after["old.txt"].mtime_ns, STAMP_UNKNOWN);
assert_eq!(
diff(&before, &after, &Rules::new(root)).changed,
vec!["old.txt".to_string()],
"content must decide when the stamp cannot"
);
}
#[test]
fn scan_watches_user_dotfiles_but_skips_product_owned_entries() {
let tmp = tempfile::TempDir::new().unwrap();
let root = tmp.path();
for dir in [
".git",
".compaction",
"generated",
"uploads",
"shared",
"notes",
"archive/2026-01-01",
"archive",
".config",
] {
std::fs::create_dir_all(root.join(dir)).unwrap();
}
let skipped = [
".git/config",
".compaction/c.txt",
"generated/g.txt",
"uploads/u.txt",
"notes/n.txt",
"archive/2026-01-01/old.txt",
".DS_Store",
"MEMORY.md",
];
let watched = [
".env",
".gitignore",
".config/settings.toml",
"keep.txt",
"shared/s.txt",
];
for path in skipped.iter().chain(watched.iter()) {
std::fs::write(root.join(path), "one").unwrap();
}
let before = scan(root, None, Scan::Refresh, &Rules::new(root)).unwrap();
assert_eq!(
before.keys().cloned().collect::<Vec<_>>(),
vec![
".config/settings.toml".to_string(),
".env".to_string(),
".gitignore".to_string(),
"keep.txt".to_string(),
"shared/s.txt".to_string(),
],
"only product/OS-owned entries must be hidden"
);
std::thread::sleep(std::time::Duration::from_millis(20));
for path in skipped.iter().chain(watched.iter()) {
std::fs::write(root.join(path), "two-longer").unwrap();
}
let after = scan(root, Some(&before), Scan::Refresh, &Rules::new(root)).unwrap();
assert_eq!(
after.keys().cloned().collect::<Vec<_>>(),
before.keys().cloned().collect::<Vec<_>>(),
"skipped entries must never enter the snapshot"
);
let changes = diff(&before, &after, &Rules::new(root));
assert!(changes.added.is_empty());
assert!(changes.removed.is_empty());
assert_eq!(changes.changed.len(), watched.len(), "{changes:?}");
for path in watched {
assert!(changes.changed.contains(&path.to_string()), "missed {path}");
}
}
#[test]
fn rules_come_from_repositories_inside_the_folder_only() {
let tmp = tempfile::TempDir::new().unwrap();
let outer = tmp.path();
init_git_repo(outer);
std::fs::write(outer.join(".gitignore"), "*.log\n").unwrap();
let root = outer.join("inner");
let nested = root.join("nested");
std::fs::create_dir_all(&nested).unwrap();
std::fs::write(root.join(".gitignore"), "*.log\n").unwrap();
std::fs::write(root.join("a.log"), "one").unwrap();
std::fs::write(root.join("b.txt"), "one").unwrap();
let no_repo = scan(&root, None, Scan::Refresh, &Rules::new(&root)).unwrap();
assert_eq!(
no_repo.keys().cloned().collect::<Vec<_>>(),
vec![
".gitignore".to_string(),
"a.log".to_string(),
"b.txt".to_string()
],
"a folder holding no repository hides nothing"
);
init_git_repo(&nested);
std::fs::write(nested.join(".gitignore"), "*.tmp\n").unwrap();
std::fs::create_dir_all(nested.join(".git/info")).unwrap();
std::fs::write(nested.join(".git/info/exclude"), "*.exe\n").unwrap();
std::fs::write(nested.join(".ignore"), "*.lock\n").unwrap();
std::fs::write(nested.join("c.tmp"), "one").unwrap();
std::fs::write(nested.join("d.log"), "one").unwrap();
std::fs::write(nested.join("e.exe"), "one").unwrap();
std::fs::write(nested.join("f.lock"), "one").unwrap();
let nested_repo = scan(&root, None, Scan::Refresh, &Rules::new(&root)).unwrap();
assert!(!nested_repo.contains_key("nested/c.tmp"), "{nested_repo:?}");
assert!(!nested_repo.contains_key("nested/e.exe"), "{nested_repo:?}");
assert!(
nested_repo.contains_key("nested/f.lock"),
"a `.ignore` file is not a git rule: {nested_repo:?}"
);
assert!(
nested_repo.contains_key("nested/d.log"),
"rules above the nested repository must not reach it: {nested_repo:?}"
);
assert!(nested_repo.contains_key("a.log"), "{nested_repo:?}");
}
#[test]
fn a_nested_repository_inside_an_ignored_folder_is_not_reported_removed() {
let tmp = tempfile::TempDir::new().unwrap();
let root = tmp.path();
init_git_repo(root);
let inner = root.join("vendor/inner");
std::fs::create_dir_all(&inner).unwrap();
std::fs::write(inner.join("keep.txt"), "one").unwrap();
init_git_repo(&inner);
let before = scan(root, None, Scan::Refresh, &Rules::new(root)).unwrap();
assert!(before.contains_key("vendor/inner/keep.txt"), "{before:?}");
std::fs::write(root.join(".gitignore"), "vendor/\n").unwrap();
let after = scan(root, Some(&before), Scan::Report, &Rules::new(root)).unwrap();
assert!(!after.contains_key("vendor/inner/keep.txt"), "{after:?}");
assert!(
diff(&before, &after, &Rules::new(root)).removed.is_empty(),
"an ignored folder takes the nested repository inside it with it"
);
}
#[test]
fn a_new_ignore_rule_never_reports_a_removal() {
let tmp = tempfile::TempDir::new().unwrap();
let root = tmp.path();
init_git_repo(root);
std::fs::write(root.join(".gitignore"), "# no rules yet\n").unwrap();
std::fs::write(root.join("kept.txt"), "kept").unwrap();
std::fs::write(root.join("noise.log"), "noise").unwrap();
std::fs::create_dir(root.join("build")).unwrap();
std::fs::write(root.join("build/a.o"), "noise").unwrap();
std::fs::write(root.join("build/b.o"), "noise").unwrap();
let before = scan(root, None, Scan::Refresh, &Rules::new(root)).unwrap();
assert!(before.contains_key("noise.log"), "{before:?}");
std::fs::write(root.join(".gitignore"), "*.log\nbuild/\n").unwrap();
let after = scan(root, Some(&before), Scan::Report, &Rules::new(root)).unwrap();
assert!(!after.contains_key("noise.log"), "{after:?}");
assert!(!after.contains_key("build/a.o"), "{after:?}");
let changes = diff(&before, &after, &Rules::new(root));
assert!(
changes.removed.is_empty(),
"an ignored path may never read as removed: {changes:?}"
);
let under_rule = scan(root, Some(&before), Scan::Refresh, &Rules::new(root)).unwrap();
std::fs::write(root.join(".gitignore"), "# rule lifted\n").unwrap();
let lifted = scan(root, Some(&under_rule), Scan::Report, &Rules::new(root)).unwrap();
assert_eq!(
diff(&under_rule, &lifted, &Rules::new(root)).added,
vec![
"build/a.o".to_string(),
"build/b.o".to_string(),
"noise.log".to_string()
]
);
}
#[test]
fn rules_never_hide_a_tracked_path() {
let (_dir, root) = init_temp_repo();
std::fs::write(root.join(".gitignore"), "*.log\nbuild/\n").unwrap();
std::fs::write(root.join("keep.log"), "tracked").unwrap();
std::fs::write(root.join("skip.log"), "untracked").unwrap();
std::fs::create_dir(root.join("build")).unwrap();
std::fs::write(root.join("build/keep.txt"), "tracked").unwrap();
std::fs::write(root.join("build/skip.txt"), "untracked").unwrap();
for path in ["keep.log", "build/keep.txt"] {
let status = std::process::Command::new("git")
.args(["add", "-f", path])
.current_dir(&root)
.status()
.expect("git add");
assert!(status.success(), "git add -f {path}");
}
let snapshot = scan(&root, None, Scan::Refresh, &Rules::new(&root)).unwrap();
assert!(snapshot.contains_key("keep.log"), "{snapshot:?}");
assert!(
snapshot.contains_key("build/keep.txt"),
"a tracked file keeps its ignored directory: {snapshot:?}"
);
assert!(!snapshot.contains_key("skip.log"), "{snapshot:?}");
assert!(!snapshot.contains_key("build/skip.txt"), "{snapshot:?}");
std::fs::remove_file(root.join("keep.log")).unwrap();
let after = scan(&root, Some(&snapshot), Scan::Report, &Rules::new(&root)).unwrap();
assert_eq!(
diff(&snapshot, &after, &Rules::new(&root)).removed,
vec!["keep.log".to_string()]
);
}
#[test]
fn rules_never_hide_a_tracked_path_inside_a_nested_repository() {
let tmp = tempfile::TempDir::new().unwrap();
let root = tmp.path();
let inner = root.join("project");
std::fs::create_dir(&inner).unwrap();
init_git_repo(&inner);
std::fs::write(inner.join(".gitignore"), "*.log\n").unwrap();
std::fs::write(inner.join("keep.log"), "tracked").unwrap();
std::fs::write(inner.join("skip.log"), "untracked").unwrap();
let status = std::process::Command::new("git")
.args(["add", "-f", "keep.log"])
.current_dir(&inner)
.status()
.expect("git add");
assert!(status.success(), "git add -f keep.log");
let snapshot = scan(root, None, Scan::Refresh, &Rules::new(root)).unwrap();
assert!(snapshot.contains_key("project/keep.log"), "{snapshot:?}");
assert!(!snapshot.contains_key("project/skip.log"), "{snapshot:?}");
}
#[test]
fn scan_prunes_shared_in_the_owners_own_folder_alone() {
let owner = crate::users::personal_workspace_path(crate::users::ADMIN_USER_NAME);
assert!(is_owner_personal_folder(&owner));
assert!(is_owner_personal_folder(&owner.join(".")));
assert!(!is_owner_personal_folder(&crate::users::userspaces_root()));
assert!(!is_owner_personal_folder(&owner.join("nested")));
let outside = tempfile::TempDir::new().unwrap();
assert!(!is_owner_personal_folder(outside.path()));
let catalogue = owner.join("shared");
std::fs::create_dir_all(&catalogue).unwrap();
let catalogue_probe = crate::util::test::ProbeFile(catalogue.join("installed_probe.txt"));
let personal_probe = crate::util::test::ProbeFile(owner.join("personal_changes_probe.txt"));
std::fs::write(&catalogue_probe.0, "one").unwrap();
std::fs::write(&personal_probe.0, "one").unwrap();
let owner_snapshot = scan(&owner, None, Scan::Refresh, &Rules::new(&owner)).unwrap();
assert!(
owner_snapshot.contains_key("personal_changes_probe.txt"),
"the owner's own material stays watched: {owner_snapshot:?}"
);
assert!(
!owner_snapshot
.keys()
.any(|path| path.starts_with("shared/")),
"the catalogue is the product's own: {owner_snapshot:?}"
);
let guest = crate::users::userspaces_root().join("pc_shared_guest");
std::fs::create_dir_all(guest.join("shared")).unwrap();
std::fs::write(guest.join("shared/own.txt"), "one").unwrap();
let guest_snapshot = scan(&guest, None, Scan::Refresh, &Rules::new(&guest)).unwrap();
assert_eq!(
guest_snapshot.keys().cloned().collect::<Vec<_>>(),
vec!["shared/own.txt".to_string()],
"another account's `shared/` is that account's own material"
);
std::fs::remove_dir_all(&guest).unwrap();
let lookalike = outside.path().join(crate::users::ADMIN_USER_NAME);
std::fs::create_dir_all(lookalike.join("shared")).unwrap();
std::fs::write(lookalike.join("shared/own.txt"), "one").unwrap();
let lookalike_snapshot =
scan(&lookalike, None, Scan::Refresh, &Rules::new(&lookalike)).unwrap();
assert_eq!(
lookalike_snapshot.keys().cloned().collect::<Vec<_>>(),
vec!["shared/own.txt".to_string()],
"only the product's own folder holds the catalogue"
);
}
#[test]
fn render_caps_listed_entries_and_summarizes_the_rest() {
let changes = Changes {
added: (0..100).map(|i| format!("f{i:03}.txt")).collect(),
changed: Vec::new(),
removed: Vec::new(),
};
let block = render(&changes).unwrap();
assert!(block.contains("…and 60 more"), "tail missing: {block}");
assert!(
!block.contains("\n\n"),
"a truncated block must not carry a stray blank line: {block}"
);
let listed = block
.lines()
.find_map(|line| line.strip_prefix("added: "))
.unwrap()
.split(", ")
.count();
assert_eq!(listed, MAX_LISTED_ENTRIES);
let short = render(&Changes {
added: vec!["a.txt".to_string()],
..Changes::default()
})
.unwrap();
assert!(
short.ends_with("a.txt\n</personal-changes>"),
"unexpected block tail: {short}"
);
}
#[test]
fn freeze_keeps_the_prefix_subtree_and_no_sibling() {
let mut previous = Snapshot::new();
for path in ["dir", "dir/a.txt", "dir/sub/b.txt", "dir.txt", "other.txt"] {
previous.insert(
path.to_string(),
FileState {
size: 1,
mtime_ns: 2,
digest: Some(3),
},
);
}
let mut out = Snapshot::new();
freeze(Some(&previous), "dir", &mut out);
assert_eq!(
out.keys().cloned().collect::<Vec<_>>(),
vec![
"dir".to_string(),
"dir/a.txt".to_string(),
"dir/sub/b.txt".to_string(),
],
"the frozen prefix must take its subtree, and only it"
);
let mut fresh = Snapshot::new();
freeze(None, "dir", &mut fresh);
assert!(fresh.is_empty());
}
#[cfg(unix)]
#[test]
fn scan_reports_a_file_replaced_by_a_dangling_symlink() {
let tmp = tempfile::TempDir::new().unwrap();
let root = tmp.path();
std::fs::write(root.join("gone.txt"), "here").unwrap();
let before = scan(root, None, Scan::Refresh, &Rules::new(root)).unwrap();
assert!(before.contains_key("gone.txt"));
std::fs::remove_file(root.join("gone.txt")).unwrap();
std::os::unix::fs::symlink("x", root.join("gone.txt")).unwrap();
let after = scan(root, Some(&before), Scan::Refresh, &Rules::new(root)).unwrap();
assert_eq!(
after["gone.txt"].digest, None,
"a broken link is never followed"
);
let changes = diff(&before, &after, &Rules::new(root));
assert_eq!(changes.changed, vec!["gone.txt".to_string()]);
}
#[cfg(unix)]
#[test]
fn scan_never_opens_a_fifo() {
let tmp = tempfile::TempDir::new().unwrap();
let root = tmp.path();
std::fs::write(root.join("ok.txt"), "ok").unwrap();
let fifo = root.join("pipe");
let c_path = std::ffi::CString::new(fifo.to_str().unwrap()).unwrap();
assert_eq!(unsafe { libc::mkfifo(c_path.as_ptr(), 0o644) }, 0);
let (tx, rx) = std::sync::mpsc::channel();
let root = root.to_path_buf();
std::thread::spawn(move || {
let _ = tx.send(scan(&root, None, Scan::Refresh, &Rules::new(&root)));
});
let snapshot = rx
.recv_timeout(std::time::Duration::from_secs(10))
.expect("a FIFO must not block the scan")
.expect("the folder is readable");
assert!(snapshot.contains_key("ok.txt"));
let pipe = snapshot
.get("pipe")
.expect("a FIFO is an entry the folder holds and must be observed");
assert_eq!(pipe.digest, None, "a FIFO must never be hashed");
}
#[tokio::test]
#[serial_test::serial(personal_changes)]
async fn refresh_and_prepend_round_trip() {
crate::util::test::init_test_stores().await;
let user = "pc_e2e_user";
let agent_id = crate::session::assistant_agent_id(user);
let conn = &crate::session::store().conn;
conn.execute(
"DELETE FROM session_metadata WHERE agent_id = ?1",
params![agent_id.clone()],
)
.await
.unwrap();
let root = crate::users::personal_workspace_path(user);
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(&root).unwrap();
std::fs::write(root.join("a.txt"), "one").unwrap();
let personal = crate::users::personal_workspace_struct(user);
assert_eq!(
prepend_changes("hi", &personal, &Role::Assistant).await,
"hi"
);
refresh(user).await;
let rows: i64 = conn
.query(
"SELECT COUNT(*) FROM session_metadata WHERE agent_id = ?1",
params![agent_id.clone()],
)
.await
.unwrap()[0]
.get::<i64>(0)
.unwrap();
assert_eq!(rows, 0, "refresh must not create a phantom metadata row");
let now = crate::db::now();
conn.execute(
"INSERT INTO session_metadata (agent_id, last_activity, user_name, workspace_name, role, created_at) \
VALUES (?1, ?2, ?3, ?4, 'assistant', ?2)",
params![agent_id.clone(), now, user, format!("personal:{user}")],
)
.await
.unwrap();
refresh(user).await;
assert_eq!(
prepend_changes("hi", &personal, &Role::Assistant).await,
"hi"
);
let stored = wait_for_baseline(user)
.await
.expect("the folder's first observation must land");
assert!(stored.contains("a.txt"), "{stored}");
std::fs::write(root.join("a.txt"), "an edited body").unwrap();
std::fs::write(root.join("b.txt"), "brand new").unwrap();
let msg = prepend_changes("hello", &personal, &Role::Assistant).await;
assert!(
msg.ends_with("hello"),
"original message must stay at the tail: {msg}"
);
assert!(
msg.starts_with("<personal-changes>"),
"block shell missing: {msg}"
);
assert!(msg.contains("changed: a.txt"), "missed change: {msg}");
assert!(msg.contains("added: b.txt"), "missed addition: {msg}");
std::fs::write(root.join("written-by-the-turn.md"), "product output").unwrap();
refresh(user).await;
assert_eq!(
prepend_changes("hello", &personal, &Role::Assistant).await,
"hello"
);
let outside = std::env::temp_dir();
let project = Workspace::ephemeral_run("project-ws", &outside);
assert_eq!(prepend_changes("x", &project, &Role::Assistant).await, "x");
let mut unnameable = Workspace::ephemeral_run("dir-x", &outside);
unnameable.name = "personal:bad/name".to_string();
assert_eq!(
prepend_changes("x", &unnameable, &Role::Assistant).await,
"x"
);
assert_eq!(
prepend_changes("x", &personal, &Role::Manager).await,
"x",
"only the Assistant's turns carry the report"
);
assert_eq!(
prepend_changes(
"x",
&crate::users::personal_workspace_struct("pc_e2e_absent_user"),
&Role::Assistant,
)
.await,
"x"
);
conn.execute(
"DELETE FROM session_metadata WHERE agent_id = ?1",
params![agent_id],
)
.await
.unwrap();
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn diff_reports_an_oversized_file_by_its_stamp() {
use std::io::{Seek, SeekFrom, Write as _};
let tmp = tempfile::TempDir::new().unwrap();
let root = tmp.path();
let big = root.join("big.bin");
std::fs::File::create(&big)
.unwrap()
.set_len(HASH_MAX_BYTES + 1)
.unwrap();
let before = scan(root, None, Scan::Refresh, &Rules::new(root)).unwrap();
assert_eq!(
before["big.bin"].digest, None,
"an oversized file is never read"
);
assert_eq!(before["big.bin"].size, HASH_MAX_BYTES + 1);
std::thread::sleep(std::time::Duration::from_millis(20));
let mut file = std::fs::OpenOptions::new().write(true).open(&big).unwrap();
file.seek(SeekFrom::Start(HASH_MAX_BYTES / 2)).unwrap();
file.write_all(b"x").unwrap();
let after = scan(root, Some(&before), Scan::Refresh, &Rules::new(root)).unwrap();
assert_eq!(
diff(&before, &after, &Rules::new(root)).changed,
vec!["big.bin".to_string()]
);
}
#[tokio::test]
#[serial_test::serial(personal_changes)]
async fn refresh_after_turn_resolves_the_owned_personal_folder() {
crate::util::test::init_test_stores().await;
let root = crate::users::userspaces_root();
let folder = root.join("pc_gate_user");
let inner = folder.join("delegated");
std::fs::create_dir_all(&inner).unwrap();
let project_dir = root.join("..").join("pc_gate_project");
std::fs::create_dir_all(&project_dir).unwrap();
let own = crate::users::personal_workspace_struct("pc_gate_user");
assert_eq!(personal_user_of(&own).as_deref(), Some("pc_gate_user"));
let delegated = Workspace::ephemeral_run("dir-delegated-0", &inner);
assert_eq!(
personal_user_of(&delegated).as_deref(),
Some("pc_gate_user")
);
let project = Workspace::from_path(&project_dir);
assert_eq!(
personal_user_of(&project),
None,
"a managed workspace is not watched"
);
let mut linked = Workspace::ephemeral_run("dir-linked-0", &project_dir);
linked.name = crate::users::personal_workspace_name("pc_gate_user");
assert_eq!(personal_user_of(&linked).as_deref(), Some("pc_gate_user"));
let agent_id = crate::session::assistant_agent_id("pc_gate_user");
crate::session::store()
.conn
.execute(
"INSERT INTO session_metadata (agent_id, last_activity, user_name, role, created_at) \
VALUES (?1, ?2, ?3, 'assistant', ?2)",
params![agent_id.clone(), crate::db::now(), "pc_gate_user"],
)
.await
.unwrap();
std::fs::write(inner.join("hand.txt"), "hand written").unwrap();
refresh_after_turn(&crate::Role::Assistant, &delegated, true).await;
let stored = wait_for_baseline("pc_gate_user")
.await
.expect("a turn inside the folder stores its baseline");
assert!(stored.contains("delegated/hand.txt"), "{stored}");
std::fs::write(inner.join("hand.txt"), "hand edited again").unwrap();
refresh_after_turn(&crate::Role::Custom, &delegated, true).await;
refresh_after_turn(&crate::Role::Assistant, &delegated, false).await;
let personal = crate::users::personal_workspace_struct("pc_gate_user");
let report = prepend_changes("x", &personal, &Role::Assistant).await;
assert!(report.contains("changed: delegated/hand.txt"), "{report}");
std::fs::remove_file(inner.join("hand.txt")).unwrap();
refresh_after_turn(&crate::Role::Assistant, &project, true).await;
assert_eq!(
crate::session::store()
.personal_changes_baseline(&agent_id)
.await
.flatten()
.as_deref(),
Some(stored.as_str())
);
crate::session::store()
.conn
.execute(
"DELETE FROM session_metadata WHERE agent_id = ?1",
params![agent_id],
)
.await
.unwrap();
let _ = std::fs::remove_dir_all(&folder);
let _ = std::fs::remove_dir_all(&project_dir);
}
#[test]
fn personal_folder_picture_counts_exactly_what_the_report_watches() {
let tmp = tempfile::TempDir::new().unwrap();
let root = tmp.path();
assert!(!is_owner_personal_folder(root));
init_git_repo(root);
for dir in [
".compaction",
"generated",
"uploads",
"notes",
"archive/2026-01-01",
"archive",
"album/deep",
"album",
".config",
"empty",
"shared",
"cache/deep",
] {
std::fs::create_dir_all(root.join(dir)).unwrap();
}
for path in [
".git/config",
".compaction/dump.md",
"generated/big.bin",
"uploads/u.png",
"notes/topic.md",
"archive/2026-01-01/old.md",
"MEMORY.md",
".DS_Store",
"album/.DS_Store",
] {
std::fs::write(root.join(path), "one").unwrap();
}
std::fs::write(root.join(".gitignore"), "secret.txt\ncache/\n").unwrap();
for path in [
"album/one.jpg",
"album/deep/two.jpg",
".config/settings.toml",
"shared/s.txt",
"top.txt",
".env",
"secret.txt",
"cache/one.bin",
] {
std::fs::write(root.join(path), "one").unwrap();
}
#[cfg(unix)]
std::os::unix::fs::symlink(root.join("album"), root.join("linked")).unwrap();
let picture = personal_folder_picture(root).expect("picture");
let snapshot =
scan(root, None, Scan::Refresh, &Rules::new(root)).expect("the report's own walk");
let mut watched: BTreeMap<String, usize> = BTreeMap::new();
let mut watched_direct = 0usize;
for key in snapshot.keys() {
match key.split_once(std::path::MAIN_SEPARATOR) {
Some((folder, _)) => *watched.entry(folder.to_string()).or_default() += 1,
None => watched_direct += 1,
}
}
let (pictured, pictured_direct) = pictured_folders(&picture);
assert_eq!(watched_direct, 3, "{snapshot:?}");
assert!(!snapshot.contains_key("secret.txt"), "{snapshot:?}");
assert!(snapshot.contains_key(".env"), "{snapshot:?}");
assert_eq!(pictured_direct, watched_direct);
assert_eq!(pictured.get("empty"), Some(&0));
assert_eq!(pictured.len(), watched.len() + 1);
for (folder, count) in &watched {
assert_eq!(pictured.get(folder), Some(count), "{folder} in {picture}");
}
for hidden in [
"generated",
"uploads",
"notes",
"archive",
".git",
".compaction",
"MEMORY.md",
"linked",
"cache", ] {
assert!(!picture.contains(hidden), "{hidden} in {picture}");
}
}
#[test]
fn personal_folder_picture_bounds_names_and_is_stable() {
let tmp = tempfile::TempDir::new().unwrap();
let root = tmp.path();
let total = MAX_PICTURED_FOLDERS + 3;
for i in 0..total {
std::fs::create_dir(root.join(format!("f{:03}", (i * 7) % total))).unwrap();
}
std::fs::write(root.join("top.txt"), "one").unwrap();
std::fs::create_dir(root.join("f000/deep")).unwrap();
std::fs::write(root.join("f000/deep/a.txt"), "one").unwrap();
let picture = personal_folder_picture(root).expect("picture");
let lines: Vec<&str> = picture.lines().collect();
assert_eq!(lines.len(), MAX_PICTURED_FOLDERS + 2, "{picture}");
assert_eq!(lines[0], "- f000/ — 1 file");
for (i, line) in lines.iter().enumerate().take(MAX_PICTURED_FOLDERS).skip(1) {
assert_eq!(*line, format!("- f{i:03}/ — 0 files"));
}
assert_eq!(lines[MAX_PICTURED_FOLDERS], "…and 3 folders not named");
assert_eq!(
lines[MAX_PICTURED_FOLDERS + 1],
"1 file directly in the folder"
);
assert_eq!(
personal_folder_picture(root).as_deref(),
Some(picture.as_str()),
"two walks over an unchanged folder must agree byte for byte"
);
std::fs::create_dir(root.join("f001/nested")).unwrap();
std::fs::write(root.join("f001/nested/b.txt"), "one").unwrap();
std::fs::write(root.join("f001/nested/c.txt"), "one").unwrap();
let grown = personal_folder_picture(root).expect("picture");
assert_eq!(
grown.lines().count(),
picture.lines().count(),
"a changed count must not change the block's shape: {grown}"
);
assert!(grown.contains("- f001/ — 2 files"), "{grown}");
}
#[test]
fn personal_folder_picture_is_absent_without_user_material() {
let tmp = tempfile::TempDir::new().unwrap();
let root = tmp.path();
assert!(personal_folder_picture(&root.join("absent")).is_none());
let kept_aside = root.join("kept-aside");
std::fs::create_dir_all(kept_aside.join(".git")).unwrap();
std::fs::write(kept_aside.join(".git/config"), "one").unwrap();
std::fs::write(kept_aside.join("MEMORY.md"), "one").unwrap();
assert!(personal_folder_picture(&kept_aside).is_none());
let only_empty = root.join("only-empty");
std::fs::create_dir_all(only_empty.join("album")).unwrap();
assert_eq!(
personal_folder_picture(&only_empty).as_deref(),
Some("- album/ — 0 files\n0 files directly in the folder")
);
}
}