Skip to main content

tano_native/
storage.rs

1use rusqlite::{params, Connection};
2use serde::Serialize;
3use sha2::{Digest, Sha256};
4use std::{
5    collections::{HashMap, HashSet},
6    fs,
7    io::{Read, Write},
8    path::{Component, Path, PathBuf},
9    time::{SystemTime, UNIX_EPOCH},
10};
11use uuid::Uuid;
12
13const MAX_DOCUMENT_BYTES: u64 = 8 * 1024 * 1024;
14
15#[path = "storage_scan_cache.rs"]
16pub(crate) mod scan_cache;
17
18#[derive(Serialize)]
19pub struct Workspace {
20    pub id: String,
21    pub name: String,
22    pub path: String,
23    pub files: Vec<Entry>,
24    #[serde(rename = "newlyCreated", skip_serializing_if = "Option::is_none")]
25    pub newly_created: Option<bool>,
26    #[serde(rename = "reinitialized", skip_serializing_if = "Option::is_none")]
27    pub reinitialized: Option<bool>,
28}
29#[derive(Serialize)]
30pub struct Entry {
31    pub id: String,
32    pub path: String,
33    name: String,
34    pub kind: String,
35    pub(crate) size: u64,
36    modified: u64,
37    #[serde(rename = "contentHash", skip_serializing_if = "Option::is_none")]
38    content_hash: Option<String>,
39}
40#[derive(Serialize, Debug)]
41pub struct Snapshot {
42    pub content: String,
43    pub hash: String,
44    modified: u64,
45}
46#[derive(Serialize, Debug)]
47#[serde(tag = "status", rename_all = "lowercase")]
48pub enum WriteResult {
49    Saved { snapshot: Snapshot },
50    Conflict { snapshot: Snapshot },
51    Missing,
52}
53
54pub fn hash(content: &[u8]) -> String {
55    format!("{:x}", Sha256::digest(content))
56}
57
58/// Unlike recycling a directory, this never removes hidden or concurrently added children.
59pub fn remove_empty_folder(root: &Path, workspace_id: &str, relative: &str) -> Result<(), String> {
60    assert_workspace_identity(root, workspace_id)?;
61    let target = resolve(root, relative, true)?;
62    if fs::symlink_metadata(root.join(relative))
63        .map_err(|e| e.to_string())?
64        .file_type()
65        .is_symlink()
66    {
67        return Err("不能转换符号链接文件夹".into());
68    }
69    if !fs::symlink_metadata(&target)
70        .map_err(|e| e.to_string())?
71        .is_dir()
72    {
73        return Err("只能移除空文件夹".into());
74    }
75    fs::remove_dir(target).map_err(|e| format!("文件夹不为空或无法移除:{e}"))
76}
77
78#[cfg(test)]
79mod empty_folder_tests {
80    use super::*;
81
82    #[test]
83    fn empty_folder_removal_preserves_hidden_children_and_checks_workspace() {
84        let temp = tempfile::tempdir().unwrap();
85        let root = temp.path().canonicalize().unwrap();
86        let id = workspace_identity(&root).unwrap();
87        fs::create_dir(root.join("folder")).unwrap();
88        fs::write(root.join("folder/.hidden"), "keep").unwrap();
89        assert!(remove_empty_folder(&root, &id, "folder").is_err());
90        assert_eq!(
91            fs::read_to_string(root.join("folder/.hidden")).unwrap(),
92            "keep"
93        );
94        fs::remove_file(root.join("folder/.hidden")).unwrap();
95        assert!(remove_empty_folder(&root, "wrong-workspace", "folder").is_err());
96        assert!(remove_empty_folder(&root, &id, "../outside").is_err());
97        assert!(remove_empty_folder(&root, &id, "").is_err());
98        assert!(remove_empty_folder(&root, &id, ".tano").is_err());
99        remove_empty_folder(&root, &id, "folder").unwrap();
100        assert!(!root.join("folder").exists());
101    }
102}
103fn hash_path(path: &Path) -> Result<String, String> {
104    #[cfg(test)]
105    scan_cache::content_read();
106    let mut file = fs::File::open(path).map_err(|e| e.to_string())?;
107    let mut digest = Sha256::new();
108    let mut buffer = [0_u8; 64 * 1024];
109    loop {
110        let read = file.read(&mut buffer).map_err(|e| e.to_string())?;
111        if read == 0 {
112            break;
113        }
114        digest.update(&buffer[..read]);
115    }
116    Ok(format!("{:x}", digest.finalize()))
117}
118fn modified(metadata: &fs::Metadata) -> u64 {
119    metadata
120        .modified()
121        .ok()
122        .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
123        .map(|d| d.as_millis() as u64)
124        .unwrap_or(0)
125}
126pub(crate) fn is_markdown(path: &Path) -> bool {
127    path.extension()
128        .is_some_and(|e| e.eq_ignore_ascii_case("md") || e.eq_ignore_ascii_case("markdown"))
129}
130pub(crate) fn is_data_table(path: &Path) -> bool {
131    path.file_name().is_some_and(|name| {
132        name.to_string_lossy()
133            .to_ascii_lowercase()
134            .ends_with(".table.json")
135    })
136}
137
138/// Existing components must be plain filesystem entries inside the selected root.
139pub fn resolve(root: &Path, relative: &str, must_exist: bool) -> Result<PathBuf, String> {
140    resolve_path(root, relative, must_exist, false)
141}
142
143pub fn is_workspace_data_path(relative: &str) -> bool {
144    let Some(path) = relative.strip_prefix(".tano/") else {
145        return false;
146    };
147    matches!(
148        path,
149        "capabilities.json"
150            | "settings.tano.json"
151            | "folders.tano.json"
152            | "plugins.tano.json"
153            | "relations.tano-relations.json"
154            | "rules/assistant.md"
155    ) || (path.starts_with("objects/") && path.ends_with(".tano.json"))
156        || path.strip_prefix("views/").is_some_and(|name| {
157            name.strip_suffix(".tano.json").is_some_and(|id| {
158                !id.is_empty()
159                    && id.len() <= 64
160                    && id
161                        .bytes()
162                        .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-')
163            })
164        })
165        || path.strip_prefix("templates/").is_some_and(|name| {
166            name.strip_suffix(".md").is_some_and(|id| {
167                id.len() == 6
168                    && id
169                        .bytes()
170                        .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())
171            })
172        })
173        || (path.starts_with("conflicts/") && path.ends_with(".json"))
174        || [
175            "annotations/",
176            "discussions/",
177            "document-layout/",
178            "suggestions/",
179            "tables/",
180            "files/",
181        ]
182        .iter()
183        .any(|prefix| {
184            path.strip_prefix(prefix).is_some_and(|name| {
185                name.strip_suffix(".tano.json").is_some_and(|id| {
186                    id.len() == 6
187                        && id
188                            .bytes()
189                            .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())
190                })
191            })
192        })
193}
194
195pub fn is_workspace_data_directory(relative: &str) -> bool {
196    if relative == ".tano" {
197        return true;
198    }
199    let Some(path) = relative.strip_prefix(".tano/") else {
200        return false;
201    };
202    matches!(
203        path,
204        "objects"
205            | "conflicts"
206            | "annotations"
207            | "discussions"
208            | "document-layout"
209            | "suggestions"
210            | "tables"
211            | "rules"
212            | "templates"
213            | "views"
214            | "files"
215    ) || path.starts_with("objects/")
216        || path.starts_with("conflicts/")
217}
218
219pub fn resolve_storage(root: &Path, relative: &str, must_exist: bool) -> Result<PathBuf, String> {
220    resolve_path(root, relative, must_exist, is_workspace_data_path(relative))
221}
222
223pub(crate) fn validate_relative_path(relative: &str, workspace_data: bool) -> Result<(), String> {
224    if relative.is_empty()
225        || relative.len() > 900
226        || relative.contains('\\')
227        || relative.contains(':')
228    {
229        return Err("无效的工作空间相对路径".into());
230    }
231    // Check raw components too: Path::components normalizes embedded `.` segments.
232    if relative
233        .split('/')
234        .any(|part| part.is_empty() || part == "." || part == "..")
235    {
236        return Err("路径不能离开工作空间".into());
237    }
238    for (index, component) in Path::new(relative).components().enumerate() {
239        let Component::Normal(name) = component else {
240            return Err("路径不能离开工作空间".into());
241        };
242        let name_string = name.to_string_lossy();
243        if (name_string.starts_with('.')
244            && !(workspace_data && index == 0 && name_string == ".tano"))
245            || name_string.ends_with('.')
246            || name_string.ends_with(' ')
247            || name_string
248                .chars()
249                .any(|c| c.is_control() || "<>\"|?*".contains(c))
250        {
251            return Err("不支持隐藏路径或保留字符".into());
252        }
253    }
254    Ok(())
255}
256
257fn resolve_path(
258    root: &Path,
259    relative: &str,
260    must_exist: bool,
261    workspace_data: bool,
262) -> Result<PathBuf, String> {
263    validate_relative_path(relative, workspace_data)?;
264    let mut current = root.to_path_buf();
265    for component in Path::new(relative).components() {
266        current.push(component.as_os_str());
267        if let Ok(metadata) = fs::symlink_metadata(&current) {
268            if metadata.file_type().is_symlink() {
269                return Err("为保护工作空间,不跟随符号链接".into());
270            }
271            let canonical = fs::canonicalize(&current).map_err(|e| e.to_string())?;
272            if !canonical.starts_with(root) {
273                return Err("路径不能离开工作空间".into());
274            }
275        }
276    }
277    if must_exist && !current.exists() {
278        return Err("文件已不存在".into());
279    }
280    let parent = current.parent().ok_or("无效路径")?;
281    if !parent.is_dir() {
282        return Err("目标文件夹不存在".into());
283    }
284    Ok(current)
285}
286
287/// Validate the whole destination before creating any parents. Hidden access is
288/// granted only to the portable data allowlist, never config.json or other dotfiles.
289pub fn ensure_file_parent(root: &Path, relative: &str) -> Result<PathBuf, String> {
290    let workspace_data = is_workspace_data_path(relative);
291    let parts: Vec<_> = relative.split('/').collect();
292    validate_relative_path(relative, workspace_data)?;
293    for count in 1..parts.len() {
294        let parent = resolve_path(root, &parts[..count].join("/"), false, workspace_data)?;
295        if !parent.exists() {
296            match fs::create_dir(&parent) {
297                Ok(()) => {}
298                Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {}
299                Err(error) => return Err(error.to_string()),
300            }
301        }
302        // Recheck a concurrently created directory, including its symlink status.
303        let parent = resolve_path(root, &parts[..count].join("/"), true, workspace_data)?;
304        if !parent.is_dir() {
305            return Err("目标父路径不是文件夹".into());
306        }
307    }
308    resolve_storage(root, relative, false)
309}
310
311pub(crate) fn identity_path(root: &Path) -> Result<PathBuf, String> {
312    let dir = root.join(".tano");
313    if fs::symlink_metadata(&dir).is_ok_and(|m| m.file_type().is_symlink() || !m.is_dir()) {
314        return Err(".tano 必须是普通文件夹,不能是符号链接".into());
315    }
316    let path = dir.join("config.json");
317    if fs::symlink_metadata(&path).is_ok_and(|m| m.file_type().is_symlink() || !m.is_file()) {
318        return Err("工作空间配置必须是普通文件,不能是符号链接".into());
319    }
320    Ok(path)
321}
322
323fn read_identity(path: &Path) -> Result<String, String> {
324    let value: serde_json::Value =
325        serde_json::from_slice(&fs::read(path).map_err(|e| e.to_string())?)
326            .map_err(|e| format!("工作空间配置损坏:{e}"))?;
327    // Missing format is the original v1 identity. Never interpret an unknown
328    // format as v1 or rewrite it while opening an older Local/Product build.
329    if value
330        .get("format")
331        .is_some_and(|format| format.as_u64() != Some(1))
332    {
333        return Err("此工作区的数据格式不受当前版本支持,请升级 Tano 或 Tano Local;已停止打开和写入,原文件已保留".into());
334    }
335    let id = value
336        .get("id")
337        .and_then(|v| v.as_str())
338        .ok_or("工作空间配置缺少 id")?;
339    Uuid::parse_str(id).map_err(|_| "工作空间 id 必须是 UUID")?;
340    Ok(id.to_string())
341}
342
343pub fn existing_workspace_identity(root: &Path) -> Result<Option<String>, String> {
344    let path = identity_path(root)?;
345    if path.exists() {
346        return read_identity(&path).map(Some);
347    }
348    if root.join(".tano").exists() {
349        return Err(".tano/config.json 缺失,已停止打开,未改动现有文件".into());
350    }
351    Ok(None)
352}
353
354pub fn assert_workspace_identity(root: &Path, expected_id: &str) -> Result<(), String> {
355    match existing_workspace_identity(root)? {
356        Some(id) if id == expected_id => Ok(()),
357        _ => Err("工作区身份已丢失或改变,已停止写入;请检查 .tano 数据".into()),
358    }
359}
360
361pub fn workspace_identity(root: &Path) -> Result<String, String> {
362    if !root.is_dir() {
363        return Err("工作文件夹已不存在或不是目录,请选择现有文件夹".into());
364    }
365    if let Some(id) = existing_workspace_identity(root)? {
366        return Ok(id);
367    }
368    let path = identity_path(root)?;
369    fs::create_dir(root.join(".tano")).map_err(|e| format!("无法创建工作空间配置:{e}"))?;
370    let id = Uuid::new_v4().to_string();
371    let created_at = SystemTime::now()
372        .duration_since(UNIX_EPOCH)
373        .map_err(|e| e.to_string())?
374        .as_millis() as u64;
375    let content = serde_json::to_string_pretty(&serde_json::json!({
376        "id": id, "format": 1, "name": "Tano", "createdAt": created_at
377    }))
378    .map_err(|e| e.to_string())?;
379    create_atomic(&path, content.as_bytes())?;
380    Ok(id)
381}
382
383pub fn database(path: &Path) -> Result<Connection, String> {
384    let mut connection = Connection::open(path).map_err(|e| e.to_string())?;
385    connection
386        .busy_timeout(std::time::Duration::from_secs(3))
387        .map_err(|e| e.to_string())?;
388    connection.execute_batch("PRAGMA journal_mode=WAL; CREATE TABLE IF NOT EXISTS files (workspace_id TEXT NOT NULL, path TEXT NOT NULL, id TEXT NOT NULL, hash TEXT NOT NULL, version INTEGER NOT NULL DEFAULT 1, deleted INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (workspace_id, path));").map_err(|e| e.to_string())?;
389    let transaction = connection
390        .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)
391        .map_err(|e| e.to_string())?;
392    let has_native_key: bool = transaction
393        .query_row(
394            "SELECT EXISTS(SELECT 1 FROM pragma_table_info('files') WHERE name='native_key')",
395            [],
396            |row| row.get(0),
397        )
398        .map_err(|e| e.to_string())?;
399    if !has_native_key {
400        transaction
401            .execute(
402                "ALTER TABLE files ADD COLUMN native_key TEXT NOT NULL DEFAULT ''",
403                [],
404            )
405            .map_err(|e| e.to_string())?;
406    }
407    transaction.execute_batch("CREATE TABLE IF NOT EXISTS reference_ids (workspace_id TEXT NOT NULL, id TEXT NOT NULL, PRIMARY KEY(workspace_id,id)); INSERT OR IGNORE INTO reference_ids SELECT workspace_id,id FROM files;").map_err(|e| e.to_string())?;
408    transaction.commit().map_err(|e| e.to_string())?;
409    crate::search::initialize(&connection)?;
410    Ok(connection)
411}
412
413pub(crate) fn cached_identity_exists(cache: &Path, workspace_id: &str) -> Result<bool, String> {
414    if !cache.exists() {
415        return Ok(false);
416    }
417    let connection = database(cache)?;
418    connection.query_row(
419        "SELECT EXISTS(SELECT 1 FROM files WHERE workspace_id=?1 UNION SELECT 1 FROM reference_ids WHERE workspace_id=?1)",
420        [workspace_id], |row| row.get(0),
421    ).map_err(|e| e.to_string())
422}
423
424pub(crate) fn display_path(path: &Path) -> String {
425    let value = path.to_string_lossy();
426    if let Some(share) = value.strip_prefix("\\\\?\\UNC\\") {
427        format!("\\\\{share}")
428    } else if let Some(local) = value.strip_prefix("\\\\?\\") {
429        local.into()
430    } else {
431        value.into_owned()
432    }
433}
434
435pub fn is_short_id(id: &str) -> bool {
436    id.len() == 6
437        && id
438            .bytes()
439            .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
440}
441
442pub(crate) fn claim_reference_id(
443    connection: &Connection,
444    workspace: &str,
445    candidate: &str,
446) -> Result<bool, String> {
447    Ok(connection
448        .execute(
449            "INSERT OR IGNORE INTO reference_ids(workspace_id,id) VALUES(?1,?2)",
450            params![workspace, candidate],
451        )
452        .map_err(|e| e.to_string())?
453        == 1)
454}
455
456pub(crate) fn allocate_reference_id(
457    connection: &Connection,
458    workspace: &str,
459) -> Result<String, String> {
460    for _ in 0..256 {
461        let candidate = Uuid::new_v4().simple().to_string()[..6].to_string();
462        if claim_reference_id(connection, workspace, &candidate)? {
463            return Ok(candidate);
464        }
465    }
466    Err("无法分配未占用的短 ID,请稍后重试".into())
467}
468
469#[cfg(test)]
470pub(crate) fn reserve_reference_id(
471    cache: &Path,
472    workspace: &str,
473    occupied: &[String],
474) -> Result<String, String> {
475    let mut connection = database(cache)?;
476    let transaction = connection
477        .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)
478        .map_err(|e| e.to_string())?;
479    for id in occupied {
480        claim_reference_id(&transaction, workspace, id)?;
481    }
482    let id = allocate_reference_id(&transaction, workspace)?;
483    transaction.commit().map_err(|e| e.to_string())?;
484    Ok(id)
485}
486
487#[cfg(windows)]
488pub(crate) fn native_key(path: &Path) -> String {
489    use std::os::windows::fs::OpenOptionsExt;
490    use std::os::windows::io::AsRawHandle;
491    use windows_sys::Win32::Storage::FileSystem::{
492        GetFileInformationByHandle, BY_HANDLE_FILE_INFORMATION, FILE_FLAG_BACKUP_SEMANTICS,
493    };
494    let Ok(file) = fs::OpenOptions::new()
495        .read(true)
496        .custom_flags(FILE_FLAG_BACKUP_SEMANTICS)
497        .open(path)
498    else {
499        return String::new();
500    };
501    let mut info: BY_HANDLE_FILE_INFORMATION = unsafe { std::mem::zeroed() };
502    if unsafe { GetFileInformationByHandle(file.as_raw_handle() as _, &mut info) } == 0 {
503        return String::new();
504    }
505    format!(
506        "{}:{}:{}",
507        info.dwVolumeSerialNumber, info.nFileIndexHigh, info.nFileIndexLow
508    )
509}
510#[cfg(unix)]
511pub(crate) fn native_key(path: &Path) -> String {
512    use std::os::unix::fs::MetadataExt;
513    fs::metadata(path)
514        .map(|metadata| format!("{}:{}", metadata.dev(), metadata.ino()))
515        .unwrap_or_default()
516}
517#[cfg(not(any(windows, unix)))]
518pub(crate) fn native_key(_path: &Path) -> String {
519    String::new()
520}
521
522const MAX_WORKSPACE_ENTRIES: usize = 20_000;
523
524pub(crate) fn ignored_workspace_name(name: &str) -> bool {
525    name.starts_with('.') || matches!(name, "node_modules" | "target" | "dist")
526}
527
528fn walk(
529    root: &Path,
530    dir: &Path,
531    entries: &mut Vec<Entry>,
532    limit: usize,
533    visited: &mut usize,
534) -> Result<(), String> {
535    for item in fs::read_dir(dir).map_err(|e| e.to_string())? {
536        let item = item.map_err(|e| e.to_string())?;
537        let name = item.file_name().to_string_lossy().to_string();
538        let path = item
539            .path()
540            .strip_prefix(root)
541            .map_err(|e| e.to_string())?
542            .to_string_lossy()
543            .replace('\\', "/");
544        let internal = path == ".tano" || path.starts_with(".tano/");
545        if path != ".tano" && ignored_workspace_name(&name) {
546            continue;
547        }
548        let metadata = fs::symlink_metadata(item.path()).map_err(|e| e.to_string())?;
549        if metadata.file_type().is_symlink() {
550            continue;
551        }
552        if !metadata.is_dir() && !metadata.is_file() {
553            continue;
554        }
555        if internal && metadata.is_file() && !is_workspace_data_path(&path) {
556            continue;
557        }
558        if *visited >= limit {
559            return Err(format!(
560                "工作空间超过 {limit} 个文件或文件夹,请选择更小的工作文件夹"
561            ));
562        }
563        *visited += 1;
564        if internal {
565            if metadata.is_dir() {
566                if is_workspace_data_directory(&path) {
567                    walk(root, &item.path(), entries, limit, visited)?;
568                }
569                continue;
570            }
571        }
572        entries.push(Entry {
573            content_hash: None,
574            id: String::new(),
575            path,
576            name,
577            kind: if metadata.is_dir() {
578                "directory"
579            } else {
580                "file"
581            }
582            .into(),
583            size: metadata.len(),
584            modified: modified(&metadata),
585        });
586        if metadata.is_dir() {
587            walk(root, &item.path(), entries, limit, visited)?;
588        }
589    }
590    Ok(())
591}
592
593struct CachedIdentity {
594    id: String,
595    hash: String,
596    native_key: String,
597    version: i64,
598    stamp: String,
599    indexed: bool,
600}
601
602fn unique_unclaimed<'a>(
603    candidates: Option<&Vec<&'a CachedIdentity>>,
604    claimed: &HashSet<String>,
605) -> Option<&'a CachedIdentity> {
606    let mut available = candidates?
607        .iter()
608        .copied()
609        .filter(|cached| !claimed.contains(&cached.id));
610    let candidate = available.next()?;
611    available.next().is_none().then_some(candidate)
612}
613
614pub fn scan(root: &Path, id: &str, cache: &Path) -> Result<Workspace, String> {
615    scan_inner(root, id, cache, true)
616}
617
618#[cfg(test)]
619pub(crate) fn scan_without_content_cache(
620    root: &Path,
621    id: &str,
622    cache: &Path,
623) -> Result<Workspace, String> {
624    scan_inner(root, id, cache, false)
625}
626
627#[cfg(test)]
628pub(crate) fn take_scan_content_reads() -> usize {
629    scan_cache::take_content_reads()
630}
631
632fn scan_inner(root: &Path, id: &str, cache: &Path, reuse: bool) -> Result<Workspace, String> {
633    crate::identity_manifest::recover_mutation(root, id, cache)?;
634    crate::identity_manifest::recover_move(root, id, cache)?;
635    let mut entries = Vec::new();
636    walk(root, root, &mut entries, MAX_WORKSPACE_ENTRIES, &mut 0)?;
637    let mut connection = database(cache)?;
638    let transaction = connection
639        .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)
640        .map_err(|e| e.to_string())?;
641    crate::identity_manifest::hydrate(&transaction, root, id)?;
642    transaction
643        .execute_batch(
644            "CREATE TABLE IF NOT EXISTS scan_fingerprints (
645        workspace_id TEXT NOT NULL, path TEXT NOT NULL, hash TEXT NOT NULL, stamp TEXT NOT NULL,
646        PRIMARY KEY(workspace_id,path));",
647        )
648        .map_err(|e| e.to_string())?;
649    let old: HashMap<String, CachedIdentity> = {
650        let mut statement = transaction
651            .prepare(
652                "SELECT f.path,f.id,f.hash,f.native_key,f.version,COALESCE(c.stamp,''),s.rowid IS NOT NULL
653                 FROM files f LEFT JOIN scan_fingerprints c
654                   ON c.workspace_id=f.workspace_id AND c.path=f.path AND c.hash=f.hash
655                 LEFT JOIN search_documents s
656                   ON s.workspace=f.workspace_id AND s.file_id=f.id AND s.path=f.path AND s.hash=f.hash
657                 WHERE f.workspace_id=? AND f.deleted=0",
658            )
659            .map_err(|e| e.to_string())?;
660        let rows = statement
661            .query_map([id], |row| {
662                Ok((
663                    row.get::<_, String>(0)?,
664                    CachedIdentity {
665                        id: row.get(1)?,
666                        hash: row.get(2)?,
667                        native_key: row.get(3)?,
668                        version: row.get(4)?,
669                        stamp: row.get(5)?,
670                        indexed: row.get(6)?,
671                    },
672                ))
673            })
674            .map_err(|e| e.to_string())?;
675        rows.collect::<Result<_, _>>().map_err(|e| e.to_string())?
676    };
677    let paths: Vec<&str> = entries
678        .iter()
679        .filter(|entry| entry.kind != "directory")
680        .map(|entry| entry.path.as_str())
681        .collect();
682    let states = scan_cache::states(root, &paths)?;
683    let native_keys: HashMap<String, String> = states
684        .iter()
685        .map(|(path, state)| (path.clone(), state.key.clone()))
686        .collect();
687    let mut current_native_counts: HashMap<&str, usize> = HashMap::new();
688    for key in native_keys.values().filter(|key| !key.is_empty()) {
689        *current_native_counts.entry(key).or_default() += 1;
690    }
691    let mut old_by_native: HashMap<&str, Vec<&CachedIdentity>> = HashMap::new();
692    for cached in old.values() {
693        if !cached.native_key.is_empty() {
694            old_by_native
695                .entry(&cached.native_key)
696                .or_default()
697                .push(cached);
698        }
699    }
700    // Reserve identities proven by the filesystem before falling back to paths.
701    // Paths may all still exist after an exchange, or a moved file's old path
702    // may already hold a newly-created document. Neither is a same-file edit.
703    let mut identities: HashMap<String, &CachedIdentity> = HashMap::new();
704    let mut claimed = HashSet::new();
705    for (path, key) in &native_keys {
706        if key.is_empty() {
707            continue;
708        }
709        let previous = old.get(path).filter(|cached| cached.native_key == *key);
710        let relocated = || {
711            let candidates = old_by_native.get(key.as_str())?;
712            (candidates.len() == 1 && current_native_counts.get(key.as_str()) == Some(&1))
713                .then_some(candidates[0])
714        };
715        if let Some(cached) = previous.or_else(relocated) {
716            if claimed.insert(cached.id.clone()) {
717                identities.insert(path.clone(), cached);
718            }
719        }
720    }
721    // Atomic-save tools replace the inode at an unchanged path. Retain its UUID
722    // unless the old native file is known to survive elsewhere in this scan.
723    for (path, key) in &native_keys {
724        if identities.contains_key(path) {
725            continue;
726        }
727        if let Some(cached) = old.get(path) {
728            let survives_elsewhere = !cached.native_key.is_empty()
729                && cached.native_key != *key
730                && current_native_counts.contains_key(cached.native_key.as_str());
731            if !survives_elsewhere && claimed.insert(cached.id.clone()) {
732                identities.insert(path.clone(), cached);
733            }
734        }
735    }
736    // Only unresolved old identities can be content-based rename candidates.
737    // Indexed lookups keep large unchanged scans linear rather than quadratic.
738    let mut missing_by_hash: HashMap<&str, Vec<&CachedIdentity>> = HashMap::new();
739    for cached in old.values().filter(|cached| {
740        !claimed.contains(&cached.id)
741            && (cached.native_key.is_empty()
742                || !current_native_counts.contains_key(cached.native_key.as_str()))
743    }) {
744        if !cached.hash.is_empty() {
745            missing_by_hash
746                .entry(&cached.hash)
747                .or_default()
748                .push(cached);
749        }
750    }
751    let mut unresolved_hashes = HashMap::new();
752    let mut current_candidates_by_hash: HashMap<String, usize> = HashMap::new();
753    if !missing_by_hash.is_empty() {
754        for entry in entries
755            .iter()
756            .filter(|entry| entry.kind == "file" && !identities.contains_key(&entry.path))
757        {
758            let digest = hash_path(&root.join(&entry.path))?;
759            *current_candidates_by_hash
760                .entry(digest.clone())
761                .or_default() += 1;
762            unresolved_hashes.insert(entry.path.clone(), digest);
763        }
764    }
765    // Retain unchanged rows instead of tombstoning and rewriting the whole index.
766    for path in old.keys().filter(|path| !native_keys.contains_key(*path)) {
767        transaction
768            .execute(
769                "UPDATE files SET deleted=1 WHERE workspace_id=?1 AND path=?2",
770                params![id, path],
771            )
772            .map_err(|e| e.to_string())?;
773    }
774    // Object IDs are source metadata. Remember imported IDs too, even after their file is deleted.
775    for entry in entries.iter().filter(|entry| {
776        entry.kind == "file"
777            && entry.path.ends_with(".tano.json")
778            && entry.size <= MAX_DOCUMENT_BYTES
779    }) {
780        if let Ok(bytes) = fs::read(root.join(&entry.path)) {
781            if let Ok(value) = serde_json::from_slice::<serde_json::Value>(&bytes) {
782                if let Some(object_id) = value
783                    .get("id")
784                    .and_then(|id| id.as_str())
785                    .filter(|id| is_short_id(id))
786                {
787                    claim_reference_id(&transaction, id, object_id)?;
788                }
789            }
790        }
791    }
792    for entry in &mut entries {
793        if entry.kind == "directory" {
794            entry.id = format!("folder:{}", entry.path);
795            continue;
796        }
797        let state = &states[&entry.path];
798        let searchable = is_markdown(Path::new(&entry.path)) && entry.size <= MAX_DOCUMENT_BYTES;
799        let cached = old.get(&entry.path).filter(|cached| {
800            reuse
801                && !state.stamp.is_empty()
802                && cached.stamp == state.stamp
803                && cached.native_key == state.key
804                && is_short_id(&cached.id)
805                && (!searchable || cached.indexed)
806        });
807        // Only searchable Markdown needs a retained buffer. Hash attachments in
808        // bounded chunks instead of allocating up to 50 MiB per file per scan.
809        let bytes = if cached.is_none() && searchable {
810            #[cfg(test)]
811            scan_cache::content_read();
812            Some(fs::read(root.join(&entry.path)).map_err(|e| e.to_string())?)
813        } else {
814            None
815        };
816        let digest = match (cached, &bytes) {
817            (Some(cached), _) => cached.hash.clone(),
818            (_, Some(bytes)) => hash(bytes),
819            _ => hash_path(&root.join(&entry.path))?,
820        };
821        entry.content_hash = (!digest.is_empty()).then(|| digest.clone());
822        if unresolved_hashes
823            .get(&entry.path)
824            .is_some_and(|before| before != &digest)
825        {
826            return Err("扫描期间文件被外部修改,请重试".into());
827        }
828        let key = &native_keys[&entry.path];
829        let identity = identities.get(&entry.path).copied().or_else(|| {
830            (current_candidates_by_hash.get(digest.as_str()) == Some(&1))
831                .then(|| unique_unclaimed(missing_by_hash.get(digest.as_str()), &claimed))
832                .flatten()
833        });
834        entry.id = match identity.filter(|cached| is_short_id(&cached.id)) {
835            Some(cached) => cached.id.clone(),
836            None => allocate_reference_id(&transaction, id)?,
837        };
838        let version = identity
839            .map(|cached| cached.version + i64::from(cached.hash != digest))
840            .unwrap_or(1);
841        claimed.insert(entry.id.clone());
842        if cached.is_none() {
843            crate::search::update(
844                &transaction,
845                id,
846                &entry.id,
847                &entry.path,
848                &digest,
849                bytes.as_deref(),
850            )?;
851        }
852        if old.get(&entry.path).is_none_or(|previous| {
853            previous.id != entry.id
854                || previous.version != version
855                || previous.hash != digest
856                || previous.native_key != *key
857        }) {
858            transaction.prepare_cached("INSERT INTO files (workspace_id, path, id, hash, version, deleted, native_key) VALUES (?1,?2,?3,?4,?6,0,?5) ON CONFLICT(workspace_id,path) DO UPDATE SET id=excluded.id, version=excluded.version, hash=excluded.hash, deleted=0, native_key=excluded.native_key")
859                .map_err(|e| e.to_string())?.execute(params![id, entry.path, entry.id, digest, key, version]).map_err(|e| e.to_string())?;
860        }
861        if cached.is_none() {
862            let after = scan_cache::state(&root.join(&entry.path));
863            // A racing writer must never associate old bytes with a newer stamp.
864            let stamp = if scan_cache::unchanged(state, &after) {
865                &state.stamp
866            } else {
867                ""
868            };
869            transaction.prepare_cached("INSERT INTO scan_fingerprints VALUES(?1,?2,?3,?4)
870                ON CONFLICT(workspace_id,path) DO UPDATE SET hash=excluded.hash,stamp=excluded.stamp")
871                .map_err(|e| e.to_string())?.execute(params![id, entry.path, digest, stamp]).map_err(|e| e.to_string())?;
872        }
873    }
874    transaction
875        .execute(
876            "DELETE FROM scan_fingerprints WHERE workspace_id=?1 AND NOT EXISTS (
877        SELECT 1 FROM files f WHERE f.workspace_id=scan_fingerprints.workspace_id
878        AND f.path=scan_fingerprints.path AND f.deleted=0)",
879            [id],
880        )
881        .map_err(|e| e.to_string())?;
882    crate::search::prune(&transaction, id)?;
883    crate::identity_manifest::publish(&transaction, root, id)?;
884    transaction.commit().map_err(|e| e.to_string())?;
885    entries.sort_by(|a, b| a.path.cmp(&b.path));
886    Ok(Workspace {
887        id: id.into(),
888        name: root
889            .file_name()
890            .unwrap_or_default()
891            .to_string_lossy()
892            .into(),
893        path: display_path(root),
894        files: entries,
895        newly_created: None,
896        reinitialized: None,
897    })
898}
899
900pub fn read(path: &Path) -> Result<Snapshot, String> {
901    if !is_markdown(path)
902        && !is_data_table(path)
903        && path.file_name().is_none_or(|name| {
904            name != "capabilities.json"
905                && !name.to_string_lossy().ends_with(".tano.json")
906                && !name.to_string_lossy().ends_with(".tano-relations.json")
907        })
908    {
909        return Err("当前版本仅编辑 Markdown 文档和数据表".into());
910    }
911    let metadata = fs::metadata(path).map_err(|e| e.to_string())?;
912    if metadata.len() > MAX_DOCUMENT_BYTES {
913        return Err("文档大于 8 MB,请使用外部编辑器".into());
914    }
915    let bytes = fs::read(path).map_err(|e| e.to_string())?;
916    let content = String::from_utf8(bytes.clone()).map_err(|_| "文档必须使用 UTF-8 编码")?;
917    Ok(Snapshot {
918        content,
919        hash: hash(&bytes),
920        modified: modified(&metadata),
921    })
922}
923
924pub fn temporary(path: &Path, content: &[u8]) -> Result<tempfile::NamedTempFile, String> {
925    let mut file = tempfile::Builder::new()
926        .prefix(".tano-")
927        .tempfile_in(path.parent().ok_or("无效路径")?)
928        .map_err(|e| e.to_string())?;
929    file.write_all(content).map_err(|e| e.to_string())?;
930    file.as_file().sync_all().map_err(|e| e.to_string())?;
931    Ok(file)
932}
933
934pub fn create_atomic(path: &Path, content: &[u8]) -> Result<(), String> {
935    temporary(path, content)?
936        .persist_noclobber(path)
937        .map_err(|e| e.to_string())?;
938    Ok(())
939}
940
941pub fn create(path: &Path, content: &str) -> Result<(), String> {
942    if !is_markdown(path)
943        && !is_data_table(path)
944        && path.file_name().is_none_or(|name| {
945            name != "capabilities.json"
946                && !name.to_string_lossy().ends_with(".tano.json")
947                && !name.to_string_lossy().ends_with(".tano-relations.json")
948        })
949    {
950        return Err("请使用 .md、.markdown 或 .table.json 扩展名".into());
951    }
952    if content.len() as u64 > MAX_DOCUMENT_BYTES {
953        return Err("文档不能超过 8 MB".into());
954    }
955    create_atomic(path, content.as_bytes())
956}
957
958pub fn write(path: &Path, content: &str, expected: &str) -> Result<WriteResult, String> {
959    if !path.exists() {
960        return Ok(WriteResult::Missing);
961    }
962    if content.len() as u64 > MAX_DOCUMENT_BYTES {
963        return Err("文档不能超过 8 MB".into());
964    }
965    let current = read(path)?;
966    if current.hash != expected {
967        return Ok(WriteResult::Conflict { snapshot: current });
968    }
969    if content == current.content {
970        return Ok(WriteResult::Saved { snapshot: current });
971    }
972    let staged = temporary(path, content.as_bytes())?;
973    staged
974        .as_file()
975        .set_permissions(fs::metadata(path).map_err(|e| e.to_string())?.permissions())
976        .map_err(|e| e.to_string())?;
977    // Check again after staging: an external writer may have changed the file during I/O.
978    if !path.exists() {
979        return Ok(WriteResult::Missing);
980    }
981    let latest = read(path)?;
982    if latest.hash != expected {
983        return Ok(WriteResult::Conflict { snapshot: latest });
984    }
985    staged.persist(path).map_err(|e| e.to_string())?;
986    // Return exactly the bytes we committed, not a potentially newer external write.
987    Ok(WriteResult::Saved {
988        snapshot: Snapshot {
989            content: content.into(),
990            hash: hash(content.as_bytes()),
991            modified: modified(&fs::metadata(path).map_err(|e| e.to_string())?),
992        },
993    })
994}
995
996#[cfg(windows)]
997fn rename_noclobber(source: &Path, destination: &Path) -> Result<(), String> {
998    use std::os::windows::ffi::OsStrExt;
999    use windows_sys::Win32::Storage::FileSystem::MoveFileExW;
1000    let from: Vec<u16> = source.as_os_str().encode_wide().chain(Some(0)).collect();
1001    let to: Vec<u16> = destination
1002        .as_os_str()
1003        .encode_wide()
1004        .chain(Some(0))
1005        .collect();
1006    // Without MOVEFILE_REPLACE_EXISTING, a concurrent destination creation fails safely.
1007    if unsafe { MoveFileExW(from.as_ptr(), to.as_ptr(), 0) } == 0 {
1008        return Err(std::io::Error::last_os_error().to_string());
1009    }
1010    Ok(())
1011}
1012
1013#[cfg(any(target_os = "android", target_os = "linux"))]
1014fn rename_noclobber(source: &Path, destination: &Path) -> Result<(), String> {
1015    use std::{ffi::CString, os::unix::ffi::OsStrExt};
1016    let from = CString::new(source.as_os_str().as_bytes()).map_err(|e| e.to_string())?;
1017    let to = CString::new(destination.as_os_str().as_bytes()).map_err(|e| e.to_string())?;
1018    // renameat2 preserves the inode and atomically refuses an existing destination.
1019    // Android API 26–29 needs the syscall because Bionic lacks the wrapper.
1020    // SAFETY: both C strings outlive the syscall; no pointer is retained.
1021    let result = unsafe {
1022        libc::syscall(
1023            libc::SYS_renameat2,
1024            libc::AT_FDCWD,
1025            from.as_ptr(),
1026            libc::AT_FDCWD,
1027            to.as_ptr(),
1028            libc::RENAME_NOREPLACE,
1029        )
1030    };
1031    if result != 0 {
1032        return Err(std::io::Error::last_os_error().to_string());
1033    }
1034    Ok(())
1035}
1036
1037#[cfg(not(any(windows, target_os = "android", target_os = "linux")))]
1038fn rename_noclobber(source: &Path, destination: &Path) -> Result<(), String> {
1039    fs::hard_link(source, destination).map_err(|e| e.to_string())?;
1040    if let Err(error) = fs::remove_file(source) {
1041        let _ = fs::remove_file(destination);
1042        return Err(error.to_string());
1043    }
1044    Ok(())
1045}
1046
1047pub fn rename(
1048    root: &Path,
1049    workspace_id: &str,
1050    from: &str,
1051    to: &str,
1052    cache: &Path,
1053) -> Result<(), String> {
1054    let source = resolve(root, from, true)?;
1055    let destination = resolve(root, to, false)?;
1056    if !source.is_file() || !(is_markdown(&destination) || is_data_table(&destination)) {
1057        return Err("只能重命名 Markdown 文件和数据表".into());
1058    }
1059    move_entry(root, workspace_id, from, to, cache)
1060}
1061
1062pub fn move_entry(
1063    root: &Path,
1064    workspace_id: &str,
1065    from: &str,
1066    to: &str,
1067    cache: &Path,
1068) -> Result<(), String> {
1069    if from == to || to.starts_with(&format!("{from}/")) {
1070        return Err("不能将文件夹移动到自身或子文件夹".into());
1071    }
1072    let source = resolve_storage(root, from, true)?;
1073    let destination = if is_workspace_data_path(to) {
1074        ensure_file_parent(root, to)?
1075    } else {
1076        resolve(root, to, false)?
1077    };
1078    let metadata = fs::symlink_metadata(&source).map_err(|e| e.to_string())?;
1079    if !metadata.is_file() && !metadata.is_dir() {
1080        return Err("只能移动普通文件或文件夹".into());
1081    }
1082    if destination.exists() {
1083        return Err("目标名称已经存在".into());
1084    }
1085    let mut connection = database(cache)?;
1086    let transaction = connection.transaction().map_err(|e| e.to_string())?;
1087    let destination_prefix = format!("{to}/");
1088    transaction
1089        .execute(
1090            "DELETE FROM files WHERE workspace_id=?1 AND (path=?2 OR substr(path,1,length(?3))=?3)",
1091            params![workspace_id, to, destination_prefix],
1092        )
1093        .map_err(|e| e.to_string())?;
1094    crate::identity_manifest::begin_move(root, workspace_id, cache, from, to)?;
1095    rename_noclobber(&source, &destination)?;
1096    let source_prefix = format!("{from}/");
1097    transaction
1098        .execute(
1099            "UPDATE files SET path=?1 || substr(path,length(?2)+1) WHERE workspace_id=?3 AND (path=?2 OR substr(path,1,length(?4))=?4)",
1100            params![to, from, workspace_id, source_prefix],
1101        )
1102        .map_err(|e| e.to_string())?;
1103    transaction.commit().map_err(|e| e.to_string())?;
1104    crate::identity_manifest::recover_move(root, workspace_id, cache)?;
1105    Ok(())
1106}
1107
1108fn check_copy_tree(source: &Path, count: &mut usize) -> Result<(), String> {
1109    let metadata = fs::symlink_metadata(source).map_err(|e| e.to_string())?;
1110    if metadata.file_type().is_symlink() || (!metadata.is_file() && !metadata.is_dir()) {
1111        return Err("副本包含符号链接或不支持的文件类型".into());
1112    }
1113    *count += 1;
1114    if *count > MAX_WORKSPACE_ENTRIES {
1115        return Err(format!("副本超过 {MAX_WORKSPACE_ENTRIES} 个文件或文件夹"));
1116    }
1117    if metadata.is_dir() {
1118        for item in fs::read_dir(source).map_err(|e| e.to_string())? {
1119            check_copy_tree(&item.map_err(|e| e.to_string())?.path(), count)?;
1120        }
1121    }
1122    Ok(())
1123}
1124
1125fn copy_tree(source: &Path, destination: &Path) -> Result<(), String> {
1126    let metadata = fs::symlink_metadata(source).map_err(|e| e.to_string())?;
1127    if metadata.file_type().is_symlink() {
1128        return Err("复制时遇到符号链接,已停止".into());
1129    }
1130    if metadata.is_dir() {
1131        fs::create_dir(destination).map_err(|e| e.to_string())?;
1132        for item in fs::read_dir(source).map_err(|e| e.to_string())? {
1133            let item = item.map_err(|e| e.to_string())?;
1134            copy_tree(&item.path(), &destination.join(item.file_name()))?;
1135        }
1136    } else if metadata.is_file() {
1137        let mut input = fs::File::open(source).map_err(|e| e.to_string())?;
1138        let mut output = fs::OpenOptions::new()
1139            .write(true)
1140            .create_new(true)
1141            .open(destination)
1142            .map_err(|e| e.to_string())?;
1143        std::io::copy(&mut input, &mut output).map_err(|e| e.to_string())?;
1144        output.sync_all().map_err(|e| e.to_string())?;
1145    } else {
1146        return Err("只能复制普通文件或文件夹".into());
1147    }
1148    Ok(())
1149}
1150
1151pub fn duplicate_entry(root: &Path, from: &str) -> Result<String, String> {
1152    let source = resolve(root, from, true)?;
1153    let metadata = fs::symlink_metadata(&source).map_err(|e| e.to_string())?;
1154    if !metadata.is_file() && !metadata.is_dir() {
1155        return Err("只能复制普通文件或文件夹".into());
1156    }
1157    let mut count = 0;
1158    check_copy_tree(&source, &mut count)?;
1159    let name = source
1160        .file_name()
1161        .and_then(|name| name.to_str())
1162        .ok_or("文件名包含无法复制的字符")?;
1163    let (stem, extension) = if metadata.is_file() && is_data_table(&source) {
1164        name.split_at(name.len() - ".table.json".len())
1165    } else if metadata.is_file() {
1166        match name.rfind('.') {
1167            Some(dot) if dot > 0 => (&name[..dot], &name[dot..]),
1168            _ => (name, ""),
1169        }
1170    } else {
1171        (name, "")
1172    };
1173    let parent = from.rsplit_once('/').map(|(parent, _)| parent);
1174    for number in 1..=10_000 {
1175        let suffix = if number == 1 {
1176            " 副本".to_string()
1177        } else {
1178            format!(" 副本 {number}")
1179        };
1180        let candidate = format!("{stem}{suffix}{extension}");
1181        let relative = match parent {
1182            Some(parent) => format!("{parent}/{candidate}"),
1183            None => candidate,
1184        };
1185        let destination = resolve(root, &relative, false)?;
1186        if destination.exists() {
1187            continue;
1188        }
1189        copy_tree(&source, &destination).map_err(|error| {
1190            format!("创建副本失败;若目标已出现,请检查部分内容:{relative}({error})")
1191        })?;
1192        return Ok(relative);
1193    }
1194    Err("副本名称已用尽".into())
1195}
1196
1197#[cfg(test)]
1198#[path = "storage_cross_volume_tests.rs"]
1199mod cross_volume_tests;
1200
1201#[cfg(test)]
1202#[path = "storage_scan_cache_tests.rs"]
1203mod scan_cache_tests;
1204
1205#[cfg(test)]
1206mod tests {
1207    use super::*;
1208
1209    #[test]
1210    fn file_core_portable_views_metadata_and_binary_revisions_survive_index_rebuild() {
1211        let temp = tempfile::tempdir().unwrap();
1212        let cache = tempfile::tempdir().unwrap();
1213        let db = cache.path().join("first.sqlite");
1214        let id = workspace_identity(temp.path()).unwrap();
1215        fs::write(temp.path().join("paper.pdf"), b"%PDF-1.7 first").unwrap();
1216        let first = scan(temp.path(), &id, &db).unwrap();
1217        let file_id = first.files[0].id.clone();
1218        assert_eq!(first.files[0].content_hash, Some(hash(b"%PDF-1.7 first")));
1219        fs::create_dir_all(temp.path().join(".tano/views")).unwrap();
1220        fs::create_dir_all(temp.path().join(".tano/files")).unwrap();
1221        let metadata = format!(".tano/files/{file_id}.tano.json");
1222        let view = ".tano/views/research.tano.json";
1223        create(
1224            &resolve_storage(&fs::canonicalize(temp.path()).unwrap(), &metadata, false).unwrap(),
1225            "{\"tags\":[\"paper\"]}",
1226        )
1227        .unwrap();
1228        create(
1229            &resolve_storage(&fs::canonicalize(temp.path()).unwrap(), view, false).unwrap(),
1230            "{\"sources\":[]}",
1231        )
1232        .unwrap();
1233        let base = read(&temp.path().join(&metadata)).unwrap();
1234        fs::write(temp.path().join(&metadata), "{\"tags\":[\"external\"]}").unwrap();
1235        assert!(matches!(
1236            write(&temp.path().join(&metadata), "{}", &base.hash).unwrap(),
1237            WriteResult::Conflict { .. }
1238        ));
1239        fs::rename(
1240            temp.path().join("paper.pdf"),
1241            temp.path().join("renamed.pdf"),
1242        )
1243        .unwrap();
1244        fs::write(temp.path().join("renamed.pdf"), b"%PDF-1.7 changed").unwrap();
1245        let next = scan(temp.path(), &id, &db).unwrap();
1246        let file = next.files.iter().find(|file| file.id == file_id).unwrap();
1247        assert_eq!(file.path, "renamed.pdf");
1248        assert_eq!(file.content_hash, Some(hash(b"%PDF-1.7 changed")));
1249        let rebuilt = scan(temp.path(), &id, &cache.path().join("rebuilt.sqlite")).unwrap();
1250        assert_eq!(
1251            rebuilt
1252                .files
1253                .iter()
1254                .find(|file| file.path == "renamed.pdf")
1255                .unwrap()
1256                .id,
1257            file_id
1258        );
1259        assert!(rebuilt.files.iter().any(|file| file.path == metadata));
1260        assert!(rebuilt.files.iter().any(|file| file.path == view));
1261        assert!(!is_workspace_data_path(".tano/views/../escape.tano.json"));
1262        assert!(!is_workspace_data_path(".tano/files/not-an-id.tano.json"));
1263    }
1264
1265    #[test]
1266    fn large_binary_has_a_streamed_revision_and_content_move_identity() {
1267        let temp = tempfile::tempdir().unwrap();
1268        let cache = tempfile::tempdir().unwrap();
1269        let db = cache.path().join("cache.sqlite");
1270        fs::File::create(temp.path().join("large.bin"))
1271            .unwrap()
1272            .set_len(51 * 1024 * 1024)
1273            .unwrap();
1274        let first = scan(temp.path(), "workspace", &db).unwrap();
1275        assert_eq!(first.files[0].content_hash.as_ref().unwrap().len(), 64);
1276        database(&db)
1277            .unwrap()
1278            .execute("UPDATE files SET native_key=''", [])
1279            .unwrap();
1280        fs::rename(temp.path().join("large.bin"), temp.path().join("moved.bin")).unwrap();
1281        let after = scan(temp.path(), "workspace", &db).unwrap();
1282        assert_eq!(first.files[0].id, after.files[0].id);
1283        assert_eq!(first.files[0].content_hash, after.files[0].content_hash);
1284    }
1285
1286    #[test]
1287    fn display_path_handles_windows_local_and_unc_prefixes() {
1288        assert_eq!(display_path(Path::new(r"\\?\C:\Notes")), r"C:\Notes");
1289        assert_eq!(
1290            display_path(Path::new(r"\\?\UNC\server\share\Notes")),
1291            r"\\server\share\Notes"
1292        );
1293    }
1294
1295    #[test]
1296    fn duplicate_entry_preserves_contents_and_chooses_unused_sibling_names() {
1297        let temp = tempfile::tempdir().unwrap();
1298        let root = fs::canonicalize(temp.path()).unwrap();
1299        fs::write(root.join("note.md"), "原文").unwrap();
1300        fs::write(root.join("note 副本.md"), "原有副本").unwrap();
1301        assert_eq!(duplicate_entry(&root, "note.md").unwrap(), "note 副本 2.md");
1302        assert_eq!(
1303            fs::read_to_string(root.join("note 副本.md")).unwrap(),
1304            "原有副本"
1305        );
1306        assert_eq!(
1307            fs::read_to_string(root.join("note 副本 2.md")).unwrap(),
1308            "原文"
1309        );
1310
1311        fs::create_dir_all(root.join("资料/子目录")).unwrap();
1312        fs::write(root.join("资料/子目录/image.bin"), [0, 1, 255]).unwrap();
1313        assert_eq!(duplicate_entry(&root, "资料").unwrap(), "资料 副本");
1314        assert_eq!(
1315            fs::read(root.join("资料 副本/子目录/image.bin")).unwrap(),
1316            [0, 1, 255]
1317        );
1318        assert_eq!(duplicate_entry(&root, "资料").unwrap(), "资料 副本 2");
1319        assert!(duplicate_entry(&root, "../outside").is_err());
1320    }
1321
1322    #[cfg(unix)]
1323    #[test]
1324    fn duplicate_entry_rejects_nested_symlinks_before_creating_a_destination() {
1325        let temp = tempfile::tempdir().unwrap();
1326        let root = fs::canonicalize(temp.path()).unwrap();
1327        fs::create_dir(root.join("资料")).unwrap();
1328        std::os::unix::fs::symlink(temp.path(), root.join("资料/shortcut")).unwrap();
1329        assert!(duplicate_entry(&root, "资料").is_err());
1330        assert!(!root.join("资料 副本").exists());
1331    }
1332
1333    #[test]
1334    fn walk_stops_at_entry_limit_even_in_one_directory() {
1335        let temp = tempfile::tempdir().unwrap();
1336        let root = fs::canonicalize(temp.path()).unwrap();
1337        for name in ["one.md", "two.md", "three.md"] {
1338            fs::write(root.join(name), name).unwrap();
1339        }
1340        let mut entries = Vec::new();
1341        let error = walk(&root, &root, &mut entries, 2, &mut 0).unwrap_err();
1342        assert!(error.contains("超过 2 个文件或文件夹"));
1343        assert_eq!(entries.len(), 2);
1344    }
1345
1346    #[test]
1347    fn reject_escape_and_hidden_paths() {
1348        let temp = tempfile::tempdir().unwrap();
1349        let root = fs::canonicalize(temp.path()).unwrap();
1350        for path in [
1351            "../outside.md",
1352            "/outside.md",
1353            "C:/outside.md",
1354            ".tano/config.json",
1355            "a\\b.md",
1356        ] {
1357            assert!(resolve(&root, path, false).is_err(), "{path}");
1358        }
1359        assert!(resolve(&root, "note.md", false).is_ok());
1360    }
1361
1362    #[test]
1363    fn stale_save_preserves_external_file() {
1364        let temp = tempfile::tempdir().unwrap();
1365        let path = temp.path().join("note.md");
1366        create(&path, "base").unwrap();
1367        let base = read(&path).unwrap();
1368        fs::write(&path, "agent").unwrap();
1369        assert!(matches!(
1370            write(&path, "human", &base.hash).unwrap(),
1371            WriteResult::Conflict { .. }
1372        ));
1373        assert_eq!(read(&path).unwrap().content, "agent");
1374    }
1375
1376    #[test]
1377    fn atomic_save_create_and_deleted_file() {
1378        let temp = tempfile::tempdir().unwrap();
1379        let path = temp.path().join("note.md");
1380        create(&path, "同页").unwrap();
1381        assert!(create(&path, "overwrite").is_err());
1382        let base = read(&path).unwrap();
1383        assert!(matches!(
1384            write(&path, "hello", &base.hash).unwrap(),
1385            WriteResult::Saved { .. }
1386        ));
1387        assert_eq!(read(&path).unwrap().content, "hello");
1388        fs::remove_file(&path).unwrap();
1389        assert!(matches!(
1390            write(&path, "resurrect", &base.hash).unwrap(),
1391            WriteResult::Missing
1392        ));
1393    }
1394
1395    #[test]
1396    fn identity_survives_rename_and_external_rename() {
1397        let temp = tempfile::tempdir().unwrap();
1398        let root = fs::canonicalize(temp.path()).unwrap();
1399        let cache_dir = tempfile::tempdir().unwrap();
1400        let db = cache_dir.path().join("cache.sqlite");
1401        let workspace_id = workspace_identity(&root).unwrap();
1402        assert_eq!(workspace_id, workspace_identity(&root).unwrap());
1403        create(&root.join("first.md"), "content").unwrap();
1404        let first = scan(&root, &workspace_id, &db).unwrap();
1405        rename(&root, &workspace_id, "first.md", "next.md", &db).unwrap();
1406        let next = scan(&root, &workspace_id, &db).unwrap();
1407        assert_eq!(first.files[0].id, next.files[0].id);
1408        fs::rename(root.join("next.md"), root.join("external.md")).unwrap();
1409        let external = scan(&root, &workspace_id, &db).unwrap();
1410        assert_eq!(first.files[0].id, external.files[0].id);
1411    }
1412
1413    #[cfg(any(windows, target_os = "linux", target_os = "android"))]
1414    #[test]
1415    fn moving_a_folder_keeps_descendant_identities_and_never_overwrites() {
1416        let temp = tempfile::tempdir().unwrap();
1417        let root = fs::canonicalize(temp.path()).unwrap();
1418        let cache_dir = tempfile::tempdir().unwrap();
1419        let db = cache_dir.path().join("cache.sqlite");
1420        let workspace_id = workspace_identity(&root).unwrap();
1421        fs::create_dir(root.join("source")).unwrap();
1422        fs::create_dir(root.join("source/nested")).unwrap();
1423        fs::create_dir(root.join("archive")).unwrap();
1424        create(&root.join("source/nested/note.md"), "content").unwrap();
1425        fs::write(root.join("source/image.png"), b"image").unwrap();
1426        let before = scan(&root, &workspace_id, &db).unwrap();
1427        let note_id = before
1428            .files
1429            .iter()
1430            .find(|entry| entry.path == "source/nested/note.md")
1431            .unwrap()
1432            .id
1433            .clone();
1434        let image_id = before
1435            .files
1436            .iter()
1437            .find(|entry| entry.path == "source/image.png")
1438            .unwrap()
1439            .id
1440            .clone();
1441
1442        move_entry(&root, &workspace_id, "source", "archive/source", &db).unwrap();
1443        let after = scan(&root, &workspace_id, &db).unwrap();
1444        assert_eq!(
1445            after
1446                .files
1447                .iter()
1448                .find(|entry| entry.path == "archive/source/nested/note.md")
1449                .unwrap()
1450                .id,
1451            note_id
1452        );
1453        assert_eq!(
1454            after
1455                .files
1456                .iter()
1457                .find(|entry| entry.path == "archive/source/image.png")
1458                .unwrap()
1459                .id,
1460            image_id
1461        );
1462        assert!(!root.join("source").exists());
1463        assert!(move_entry(
1464            &root,
1465            &workspace_id,
1466            "archive/source",
1467            "archive/source/nested/copy",
1468            &db
1469        )
1470        .is_err());
1471        fs::create_dir(root.join("source")).unwrap();
1472        assert!(move_entry(&root, &workspace_id, "archive/source", "source", &db).is_err());
1473        assert_eq!(
1474            fs::read(root.join("archive/source/image.png")).unwrap(),
1475            b"image"
1476        );
1477    }
1478
1479    #[test]
1480    fn binary_identity_survives_rename_and_in_place_edit() {
1481        let temp = tempfile::tempdir().unwrap();
1482        let cache = tempfile::tempdir().unwrap();
1483        let db = cache.path().join("cache.sqlite");
1484        let source = temp.path().join("image.bin");
1485        let target = temp.path().join("renamed.bin");
1486        fs::write(&source, [0, 255, 128, 1]).unwrap();
1487        let first = scan(temp.path(), "workspace", &db).unwrap();
1488        fs::rename(&source, &target).unwrap();
1489        fs::write(&target, [2, 254, 129, 3]).unwrap();
1490        let next = scan(temp.path(), "workspace", &db).unwrap();
1491        assert_eq!(first.files[0].id, next.files[0].id);
1492        assert_eq!(next.files[0].path, "renamed.bin");
1493        let version: i64 = database(&db)
1494            .unwrap()
1495            .query_row(
1496                "SELECT version FROM files WHERE path='renamed.bin'",
1497                [],
1498                |row| row.get(0),
1499            )
1500            .unwrap();
1501        assert_eq!(version, 2);
1502        // Identical content in two different files must not collapse their identities.
1503        fs::copy(&target, &source).unwrap();
1504        let copied = scan(temp.path(), "workspace", &db).unwrap();
1505        assert_ne!(copied.files[0].id, copied.files[1].id);
1506    }
1507
1508    #[test]
1509    fn identity_follows_exchanged_files_and_does_not_transfer_to_a_replacement() {
1510        let temp = tempfile::tempdir().unwrap();
1511        let cache = tempfile::tempdir().unwrap();
1512        let db = cache.path().join("cache.sqlite");
1513        let root = temp.path();
1514        fs::write(root.join("first.md"), "first document marker").unwrap();
1515        fs::write(root.join("second.md"), "second document marker").unwrap();
1516        let ids = |snapshot: Workspace| -> HashMap<String, String> {
1517            snapshot
1518                .files
1519                .into_iter()
1520                .map(|entry| (entry.path, entry.id))
1521                .collect()
1522        };
1523        let before = ids(scan(root, "workspace", &db).unwrap());
1524        fs::rename(root.join("first.md"), root.join("temporary.md")).unwrap();
1525        fs::rename(root.join("second.md"), root.join("first.md")).unwrap();
1526        fs::rename(root.join("temporary.md"), root.join("second.md")).unwrap();
1527        fs::write(
1528            root.join("second.md"),
1529            "first document edited during exchange",
1530        )
1531        .unwrap();
1532        let exchanged = ids(scan(root, "workspace", &db).unwrap());
1533        assert_eq!(exchanged["first.md"], before["second.md"]);
1534        assert_eq!(exchanged["second.md"], before["first.md"]);
1535        let hits = crate::search::query(&db, "workspace", "edited during exchange").unwrap();
1536        assert_eq!(hits[0].id, before["first.md"]);
1537        assert_eq!(hits[0].path, "second.md");
1538        fs::rename(root.join("first.md"), root.join("moved.md")).unwrap();
1539        fs::write(root.join("first.md"), "new document occupies old path").unwrap();
1540        let replaced = ids(scan(root, "workspace", &db).unwrap());
1541        assert_eq!(replaced["moved.md"], before["second.md"]);
1542        assert_ne!(replaced["first.md"], before["second.md"]);
1543        assert_ne!(replaced["first.md"], before["first.md"]);
1544        assert_eq!(replaced.values().collect::<HashSet<_>>().len(), 3);
1545        // Atomic-save tools replace an inode without moving the original file
1546        // elsewhere. That remains an edit of the same path/UUID.
1547        let saved = read(&root.join("first.md")).unwrap();
1548        assert!(matches!(
1549            write(&root.join("first.md"), "atomic editor update", &saved.hash).unwrap(),
1550            WriteResult::Saved { .. }
1551        ));
1552        let atomic = ids(scan(root, "workspace", &db).unwrap());
1553        assert_eq!(atomic["first.md"], replaced["first.md"]);
1554    }
1555
1556    #[cfg(not(target_os = "android"))]
1557    #[test]
1558    fn a_new_hardlink_does_not_steal_the_existing_paths_identity() {
1559        let temp = tempfile::tempdir().unwrap();
1560        let cache = tempfile::tempdir().unwrap();
1561        let db = cache.path().join("cache.sqlite");
1562        fs::write(temp.path().join("original.md"), "shared bytes").unwrap();
1563        let before = scan(temp.path(), "workspace", &db).unwrap();
1564        fs::hard_link(
1565            temp.path().join("original.md"),
1566            temp.path().join("alias.md"),
1567        )
1568        .unwrap();
1569        let after = scan(temp.path(), "workspace", &db).unwrap();
1570        assert_eq!(
1571            after
1572                .files
1573                .iter()
1574                .find(|entry| entry.path == "original.md")
1575                .unwrap()
1576                .id,
1577            before.files[0].id
1578        );
1579        assert_ne!(after.files[0].id, after.files[1].id);
1580        // If both hardlink paths move, their shared native key is ambiguous.
1581        // A new equal-content file at the old path cannot claim either UUID.
1582        fs::rename(
1583            temp.path().join("original.md"),
1584            temp.path().join("moved-original.md"),
1585        )
1586        .unwrap();
1587        fs::rename(
1588            temp.path().join("alias.md"),
1589            temp.path().join("moved-alias.md"),
1590        )
1591        .unwrap();
1592        fs::write(temp.path().join("original.md"), "shared bytes").unwrap();
1593        let moved = scan(temp.path(), "workspace", &db).unwrap();
1594        for entry in &moved.files {
1595            assert!(after.files.iter().all(|old| old.id != entry.id));
1596        }
1597        assert_eq!(
1598            moved
1599                .files
1600                .iter()
1601                .map(|entry| &entry.id)
1602                .collect::<HashSet<_>>()
1603                .len(),
1604            3
1605        );
1606    }
1607
1608    #[test]
1609    fn replaces_non_short_cached_identities_without_rewriting_content() {
1610        let temp = tempfile::tempdir().unwrap();
1611        let cache = tempfile::tempdir().unwrap();
1612        let db = cache.path().join("cache.sqlite");
1613        fs::write(temp.path().join("note.md"), "old").unwrap();
1614        let connection = Connection::open(&db).unwrap();
1615        connection.execute_batch("CREATE TABLE files (workspace_id TEXT NOT NULL, path TEXT NOT NULL, id TEXT NOT NULL, hash TEXT NOT NULL, version INTEGER NOT NULL DEFAULT 1, deleted INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (workspace_id,path));").unwrap();
1616        connection
1617            .execute(
1618                "INSERT INTO files VALUES ('workspace','note.md','retained-id',?1,7,0)",
1619                [hash(b"old")],
1620            )
1621            .unwrap();
1622        drop(connection);
1623        let migrated = scan(temp.path(), "workspace", &db).unwrap();
1624        assert!(is_short_id(&migrated.files[0].id));
1625        assert_eq!(
1626            fs::read_to_string(temp.path().join("note.md")).unwrap(),
1627            "old"
1628        );
1629        assert_eq!(
1630            scan(temp.path(), "workspace", &db).unwrap().files[0].id,
1631            migrated.files[0].id
1632        );
1633        let connection = database(&db).unwrap();
1634        let (version, key): (i64, String) = connection
1635            .query_row(
1636                "SELECT version,native_key FROM files WHERE path='note.md'",
1637                [],
1638                |row| Ok((row.get(0)?, row.get(1)?)),
1639            )
1640            .unwrap();
1641        assert_eq!(version, 7);
1642        assert!(!key.is_empty());
1643    }
1644
1645    #[test]
1646    fn rename_candidates_keep_ambiguous_hashes_separate() {
1647        let temp = tempfile::tempdir().unwrap();
1648        let cache = tempfile::tempdir().unwrap();
1649        let db = cache.path().join("cache.sqlite");
1650        for name in ["one.md", "two.md"] {
1651            fs::write(temp.path().join(name), "identical content").unwrap();
1652        }
1653        let first = scan(temp.path(), "workspace", &db).unwrap();
1654        // Simulate a legacy/cross-volume cache with no native identity: two
1655        // vanished equal-content files must not be assigned by iteration order.
1656        database(&db)
1657            .unwrap()
1658            .execute("UPDATE files SET native_key=''", [])
1659            .unwrap();
1660        fs::rename(temp.path().join("one.md"), temp.path().join("new-one.md")).unwrap();
1661        fs::rename(temp.path().join("two.md"), temp.path().join("new-two.md")).unwrap();
1662        let next = scan(temp.path(), "workspace", &db).unwrap();
1663        assert_ne!(next.files[0].id, next.files[1].id);
1664        for file in &next.files {
1665            assert!(first.files.iter().all(|old| old.id != file.id));
1666        }
1667
1668        // Native identities distinguish identical content, including a source
1669        // renamed and edited while another equal-content source also moves.
1670        fs::rename(
1671            temp.path().join("new-one.md"),
1672            temp.path().join("final-one.md"),
1673        )
1674        .unwrap();
1675        fs::write(temp.path().join("final-one.md"), "changed while renamed").unwrap();
1676        fs::rename(
1677            temp.path().join("new-two.md"),
1678            temp.path().join("final-two.md"),
1679        )
1680        .unwrap();
1681        let final_scan = scan(temp.path(), "workspace", &db).unwrap();
1682        assert_eq!(next.files[0].id, final_scan.files[0].id);
1683        assert_eq!(next.files[1].id, final_scan.files[1].id);
1684    }
1685
1686    #[test]
1687    fn one_missing_identity_is_not_assigned_to_either_equal_content_copy() {
1688        let directory = tempfile::tempdir().unwrap();
1689        let data = tempfile::tempdir().unwrap();
1690        let root = fs::canonicalize(directory.path()).unwrap();
1691        let id = workspace_identity(&root).unwrap();
1692        let cache = data.path().join("index.sqlite");
1693        fs::write(root.join("old.md"), "same bytes").unwrap();
1694        let original = scan(&root, &id, &cache).unwrap().files[0].id.clone();
1695        fs::rename(root.join("old.md"), root.join("copy-a.md")).unwrap();
1696        fs::write(root.join("copy-b.md"), "same bytes").unwrap();
1697        fs::remove_file(&cache).unwrap();
1698        let restored = scan(&root, &id, &cache).unwrap();
1699        assert_eq!(restored.files.len(), 2);
1700        assert!(restored.files.iter().all(|file| file.id != original));
1701        assert_ne!(restored.files[0].id, restored.files[1].id);
1702        assert!(crate::identity_manifest::read(&root, &id)
1703            .unwrap()
1704            .unwrap()
1705            .reserved_ids
1706            .contains(&original));
1707    }
1708
1709    #[test]
1710    fn rename_never_overwrites_an_existing_document() {
1711        let temp = tempfile::tempdir().unwrap();
1712        let root = fs::canonicalize(temp.path()).unwrap();
1713        let cache_dir = tempfile::tempdir().unwrap();
1714        let db = cache_dir.path().join("cache.sqlite");
1715        create(&root.join("source.md"), "human").unwrap();
1716        create(&root.join("destination.md"), "agent").unwrap();
1717        assert!(rename(&root, "workspace", "source.md", "destination.md", &db).is_err());
1718        assert_eq!(read(&root.join("source.md")).unwrap().content, "human");
1719        assert_eq!(read(&root.join("destination.md")).unwrap().content, "agent");
1720    }
1721
1722    #[test]
1723    fn rejects_non_utf8_without_modifying_the_file() {
1724        let temp = tempfile::tempdir().unwrap();
1725        let path = temp.path().join("legacy.md");
1726        let original = [0xff, 0xfe, 0x61, 0x00];
1727        fs::write(&path, original).unwrap();
1728        assert!(read(&path).is_err());
1729        assert!(write(&path, "new", &hash(&original)).is_err());
1730        assert_eq!(fs::read(&path).unwrap(), original);
1731    }
1732
1733    #[test]
1734    fn invalid_workspace_configuration_is_not_replaced() {
1735        let temp = tempfile::tempdir().unwrap();
1736        let dir = temp.path().join(".tano");
1737        fs::create_dir(&dir).unwrap();
1738        let path = dir.join("config.json");
1739        fs::write(&path, "invalid JSON").unwrap();
1740        assert!(workspace_identity(temp.path()).is_err());
1741        assert_eq!(fs::read_to_string(path).unwrap(), "invalid JSON");
1742    }
1743
1744    #[test]
1745    fn unsupported_workspace_formats_stop_open_and_active_session_writes() {
1746        let temp = tempfile::tempdir().unwrap();
1747        let id = workspace_identity(temp.path()).unwrap();
1748        let config = temp.path().join(".tano/config.json");
1749        let note = temp.path().join("note.md");
1750        fs::write(&note, "external content").unwrap();
1751        for format in [
1752            serde_json::json!(2),
1753            serde_json::json!(0),
1754            serde_json::json!(-1),
1755            serde_json::json!(1.5),
1756            serde_json::json!("1"),
1757            serde_json::Value::Null,
1758        ] {
1759            let bytes =
1760                serde_json::to_vec(&serde_json::json!({"id": id, "format": format})).unwrap();
1761            fs::write(&config, &bytes).unwrap();
1762            assert!(workspace_identity(temp.path())
1763                .unwrap_err()
1764                .contains("数据格式"));
1765            assert!(assert_workspace_identity(temp.path(), &id).is_err());
1766            assert_eq!(fs::read(&config).unwrap(), bytes);
1767            assert_eq!(fs::read_to_string(&note).unwrap(), "external content");
1768        }
1769        fs::write(&config, serde_json::json!({"id": id}).to_string()).unwrap();
1770        assert_eq!(workspace_identity(temp.path()).unwrap(), id);
1771    }
1772
1773    #[test]
1774    fn old_hidden_data_is_ignored_and_never_migrated() {
1775        let temp = tempfile::tempdir().unwrap();
1776        let data = tempfile::tempdir().unwrap();
1777        let root = fs::canonicalize(temp.path()).unwrap();
1778        let cache = data.path().join("index.sqlite");
1779        fs::create_dir_all(root.join(".workspace/objects")).unwrap();
1780        let old_id = Uuid::new_v4().to_string();
1781        let config = serde_json::json!({ "id": old_id, "format": 1 }).to_string();
1782        fs::write(root.join(".workspace/config.json"), &config).unwrap();
1783        fs::write(
1784            root.join(".workspace/objects/a00001.tano.json"),
1785            "original bytes",
1786        )
1787        .unwrap();
1788        fs::write(root.join("note.md"), "# Note").unwrap();
1789
1790        let id = workspace_identity(&root).unwrap();
1791        assert_ne!(id, old_id);
1792        assert_eq!(workspace_identity(&root).unwrap(), id);
1793        let indexed = scan(&root, &id, &cache).unwrap();
1794        assert_eq!(indexed.files.len(), 1);
1795        assert_eq!(indexed.files[0].path, "note.md");
1796        assert_eq!(
1797            fs::read_to_string(root.join(".workspace/config.json")).unwrap(),
1798            config
1799        );
1800        assert_eq!(
1801            fs::read_to_string(root.join(".workspace/objects/a00001.tano.json")).unwrap(),
1802            "original bytes"
1803        );
1804        assert!(!root.join(".tano/objects").exists());
1805
1806        for path in [
1807            ".workspace/config.json",
1808            ".workspace/objects/a00001.tano.json",
1809            ".workspace/capabilities.json",
1810            ".workspace/relations.tano-relations.json",
1811            ".workspace/conflicts/copy.json",
1812        ] {
1813            assert!(!is_workspace_data_path(path));
1814            assert!(resolve_storage(&root, path, false).is_err());
1815            assert!(ensure_file_parent(&root, path).is_err());
1816        }
1817        assert!(!is_workspace_data_directory(".workspace"));
1818        for path in [
1819            ".tano/annotations/a0b1c2.tano.json",
1820            ".tano/discussions/a0b1c2.tano.json",
1821            ".tano/document-layout/a0b1c2.tano.json",
1822            ".tano/suggestions/a0b1c2.tano.json",
1823            ".tano/rules/assistant.md",
1824            ".tano/templates/a0b1c2.md",
1825        ] {
1826            assert!(is_workspace_data_path(path));
1827            assert!(ensure_file_parent(&root, path).is_ok());
1828            assert!(resolve_storage(&root, path, false).is_ok());
1829        }
1830        assert!(!is_workspace_data_path(
1831            ".tano/annotations/not-an-id.tano.json"
1832        ));
1833        assert!(!is_workspace_data_directory(".workspace/objects"));
1834    }
1835
1836    #[test]
1837    fn unrelated_hidden_configs_do_not_affect_tano_identity() {
1838        let temp = tempfile::tempdir().unwrap();
1839        let root = fs::canonicalize(temp.path()).unwrap();
1840        let id = workspace_identity(&root).unwrap();
1841        let config = fs::read(root.join(".tano/config.json")).unwrap();
1842        fs::create_dir(root.join(".workspace")).unwrap();
1843        let old = root.join(".workspace/config.json");
1844        fs::write(&old, "invalid JSON").unwrap();
1845        assert_eq!(workspace_identity(&root).unwrap(), id);
1846        assert_eq!(fs::read_to_string(&old).unwrap(), "invalid JSON");
1847        assert_eq!(fs::read(root.join(".tano/config.json")).unwrap(), config);
1848        assert!(resolve_storage(&root, ".tano/config.json", true).is_err());
1849    }
1850
1851    #[test]
1852    fn data_tables_are_portable_and_use_compared_writes() {
1853        let temp = tempfile::tempdir().unwrap();
1854        let root = fs::canonicalize(temp.path()).unwrap();
1855        let cache = tempfile::tempdir().unwrap();
1856        let id = workspace_identity(&root).unwrap();
1857        let relative = ".tano/tables/a0b1c2.tano.json";
1858        let path = ensure_file_parent(&root, relative).unwrap();
1859        create(&path, r#"{"version":1,"id":"a0b1c2","rows":[]}"#).unwrap();
1860        let base = read(&path).unwrap();
1861        let scanned = scan(&root, &id, &cache.path().join("index.sqlite")).unwrap();
1862        assert!(scanned.files.iter().any(|entry| entry.path == relative));
1863        fs::write(&path, "external table").unwrap();
1864        assert!(matches!(
1865            write(&path, "local table", &base.hash).unwrap(),
1866            WriteResult::Conflict { .. }
1867        ));
1868        assert_eq!(read(&path).unwrap().content, "external table");
1869        for invalid in [
1870            ".tano/tables/not-an-id.tano.json",
1871            ".tano/tables/../config.json",
1872            ".tano/tables/ABCDEF.tano.json",
1873        ] {
1874            assert!(resolve_storage(&root, invalid, false).is_err());
1875        }
1876    }
1877
1878    #[test]
1879    fn visible_data_tables_move_duplicate_and_save_as_ordinary_workspace_files() {
1880        let temp = tempfile::tempdir().unwrap();
1881        let root = fs::canonicalize(temp.path()).unwrap();
1882        let cache = tempfile::tempdir().unwrap();
1883        let db = cache.path().join("index.sqlite");
1884        let workspace_id = workspace_identity(&root).unwrap();
1885        fs::create_dir(root.join("研究")).unwrap();
1886        let source = resolve(&root, "研究/候选.table.json", false).unwrap();
1887        create(&source, r#"{"version":1,"id":"a0b1c2","rows":[]}"#).unwrap();
1888        let scanned = scan(&root, &workspace_id, &db).unwrap();
1889        let file_id = scanned
1890            .files
1891            .iter()
1892            .find(|file| file.path == "研究/候选.table.json")
1893            .unwrap()
1894            .id
1895            .clone();
1896        rename(
1897            &root,
1898            &workspace_id,
1899            "研究/候选.table.json",
1900            "研究/改名.table.json",
1901            &db,
1902        )
1903        .unwrap();
1904        move_entry(&root, &workspace_id, "研究", "归档", &db).unwrap();
1905        let moved = scan(&root, &workspace_id, &db).unwrap();
1906        assert_eq!(
1907            moved
1908                .files
1909                .iter()
1910                .find(|file| file.path == "归档/改名.table.json")
1911                .unwrap()
1912                .id,
1913            file_id
1914        );
1915        let path = resolve(&root, "归档/改名.table.json", true).unwrap();
1916        let base = read(&path).unwrap();
1917        assert!(matches!(
1918            write(&path, "changed", &base.hash).unwrap(),
1919            WriteResult::Saved { .. }
1920        ));
1921        assert!(matches!(
1922            write(&path, "stale", &base.hash).unwrap(),
1923            WriteResult::Conflict { .. }
1924        ));
1925        assert_eq!(
1926            duplicate_entry(&root, "归档/改名.table.json").unwrap(),
1927            "归档/改名 副本.table.json"
1928        );
1929        assert_eq!(
1930            read(&root.join("归档/改名 副本.table.json"))
1931                .unwrap()
1932                .content,
1933            "changed"
1934        );
1935        assert!(!source.exists());
1936        assert!(resolve(&root, "../outside.table.json", false).is_err());
1937    }
1938
1939    #[test]
1940    fn table_layout_files_are_portable_and_use_compared_writes() {
1941        let temp = tempfile::tempdir().unwrap();
1942        let root = fs::canonicalize(temp.path()).unwrap();
1943        let cache = tempfile::tempdir().unwrap();
1944        let id = workspace_identity(&root).unwrap();
1945        let relative = ".tano/document-layout/a0b1c2.tano.json";
1946        let path = ensure_file_parent(&root, relative).unwrap();
1947        create(&path, r#"{"version":1,"fileId":"a0b1c2","tables":[]}"#).unwrap();
1948        let base = read(&path).unwrap();
1949        let scanned = scan(&root, &id, &cache.path().join("index.sqlite")).unwrap();
1950        assert!(scanned.files.iter().any(|entry| entry.path == relative));
1951        fs::write(&path, "external layout").unwrap();
1952        assert!(matches!(
1953            write(&path, "local layout", &base.hash).unwrap(),
1954            WriteResult::Conflict { .. }
1955        ));
1956        assert_eq!(read(&path).unwrap().content, "external layout");
1957        assert!(
1958            resolve_storage(&root, ".tano/document-layout/not-an-id.tano.json", false).is_err()
1959        );
1960    }
1961
1962    #[test]
1963    fn suggestion_files_are_portable_and_never_overwrite_external_edits() {
1964        let temp = tempfile::tempdir().unwrap();
1965        let root = fs::canonicalize(temp.path()).unwrap();
1966        let cache = tempfile::tempdir().unwrap();
1967        let id = workspace_identity(&root).unwrap();
1968        let relative = ".tano/suggestions/a0b1c2.tano.json";
1969        let path = ensure_file_parent(&root, relative).unwrap();
1970        create(&path, r#"{"version":1,"suggestions":[]}"#).unwrap();
1971        let base = read(&path).unwrap();
1972        let scanned = scan(&root, &id, &cache.path().join("index.sqlite")).unwrap();
1973        assert!(scanned.files.iter().any(|entry| entry.path == relative));
1974        fs::write(&path, "external suggestion review").unwrap();
1975        assert!(matches!(
1976            write(&path, "local suggestion review", &base.hash).unwrap(),
1977            WriteResult::Conflict { .. }
1978        ));
1979        assert_eq!(read(&path).unwrap().content, "external suggestion review");
1980        assert!(resolve_storage(&root, ".tano/suggestions/not-an-id.tano.json", false).is_err());
1981        assert!(resolve_storage(&root, ".tano/suggestions/../config.json", false).is_err());
1982    }
1983
1984    #[test]
1985    fn portable_capabilities_use_conflict_checked_writes() {
1986        let temp = tempfile::tempdir().unwrap();
1987        let root = fs::canonicalize(temp.path()).unwrap();
1988        let path = ensure_file_parent(&root, ".tano/capabilities.json").unwrap();
1989        create(&path, "{\"features\":{\"math\":true}}").unwrap();
1990        let base = read(&path).unwrap();
1991        assert!(create(&path, "{}").is_err());
1992        fs::write(&path, "{\"features\":{\"math\":false}}").unwrap();
1993        assert!(matches!(
1994            write(&path, "{}", &base.hash).unwrap(),
1995            WriteResult::Conflict { .. }
1996        ));
1997        assert_eq!(
1998            read(&path).unwrap().content,
1999            "{\"features\":{\"math\":false}}"
2000        );
2001        let latest = read(&path).unwrap();
2002        assert!(matches!(
2003            write(&path, "{}", &latest.hash).unwrap(),
2004            WriteResult::Saved { .. }
2005        ));
2006        assert!(create(&temp.path().join("other.json"), "{}").is_err());
2007    }
2008
2009    #[test]
2010    fn workspace_settings_are_portable_and_preserve_external_edits() {
2011        let temp = tempfile::tempdir().unwrap();
2012        let root = fs::canonicalize(temp.path()).unwrap();
2013        let relative = ".tano/settings.tano.json";
2014        assert!(is_workspace_data_path(relative));
2015        let path = ensure_file_parent(&root, relative).unwrap();
2016        create(&path, "{\"version\":1,\"name\":\"A\"}").unwrap();
2017        let base = read(&path).unwrap();
2018        fs::write(&path, "{\"version\":1,\"name\":\"B\"}").unwrap();
2019        assert!(matches!(
2020            write(&path, "{}", &base.hash).unwrap(),
2021            WriteResult::Conflict { .. }
2022        ));
2023        assert_eq!(
2024            read(&path).unwrap().content,
2025            "{\"version\":1,\"name\":\"B\"}"
2026        );
2027    }
2028    #[test]
2029    fn plugin_rules_are_portable_and_conflict_checked() {
2030        let temp = tempfile::tempdir().unwrap();
2031        let root = fs::canonicalize(temp.path()).unwrap();
2032        let relative = ".tano/plugins.tano.json";
2033        assert!(is_workspace_data_path(relative));
2034        assert!(!is_workspace_data_path(".tano/plugins/main.js"));
2035        let path = ensure_file_parent(&root, relative).unwrap();
2036        create(&path, "{\"version\":1,\"plugins\":[]}").unwrap();
2037        let base = read(&path).unwrap();
2038        fs::write(&path, "external rules").unwrap();
2039        assert!(matches!(
2040            write(&path, "new rules", &base.hash).unwrap(),
2041            WriteResult::Conflict { .. }
2042        ));
2043        assert_eq!(read(&path).unwrap().content, "external rules");
2044    }
2045    #[test]
2046    fn workspace_objects_use_conflict_checked_writes() {
2047        let temp = tempfile::tempdir().unwrap();
2048        let root = fs::canonicalize(temp.path()).unwrap();
2049        let path = ensure_file_parent(&root, ".tano/objects/person.tano.json").unwrap();
2050        create(&path, "{\"id\":\"a\",\"name\":\"Alice\"}").unwrap();
2051        let base = read(&path).unwrap();
2052        assert!(create(&path, "{}").is_err());
2053        fs::write(&path, "external object edit").unwrap();
2054        assert!(matches!(
2055            write(&path, "ours", &base.hash).unwrap(),
2056            WriteResult::Conflict { .. }
2057        ));
2058        assert_eq!(read(&path).unwrap().content, "external object edit");
2059        let latest = read(&path).unwrap();
2060        assert!(matches!(
2061            write(&path, "updated", &latest.hash).unwrap(),
2062            WriteResult::Saved { .. }
2063        ));
2064        assert!(create(&temp.path().join("other.json"), "{}").is_err());
2065    }
2066
2067    #[test]
2068    fn relation_files_use_compared_writes_without_opening_arbitrary_json() {
2069        let temp = tempfile::tempdir().unwrap();
2070        let root = fs::canonicalize(temp.path()).unwrap();
2071        let path = ensure_file_parent(&root, ".tano/relations.tano-relations.json").unwrap();
2072        create(&path, r#"{"version":1,"relations":[]}"#).unwrap();
2073        let base = read(&path).unwrap();
2074        fs::write(&path, r#"{"version":1,"relations":[],"external":true}"#).unwrap();
2075        assert!(matches!(
2076            write(&path, &base.content, &base.hash).unwrap(),
2077            WriteResult::Conflict { .. }
2078        ));
2079        let latest = read(&path).unwrap();
2080        assert!(matches!(
2081            write(&path, &base.content, &latest.hash).unwrap(),
2082            WriteResult::Saved { .. }
2083        ));
2084        assert!(create(&temp.path().join("plugin.json"), "{}").is_err());
2085    }
2086
2087    #[test]
2088    fn scanner_indexes_changes_without_putting_cache_in_workspace() {
2089        let temp = tempfile::tempdir().unwrap();
2090        let root = fs::canonicalize(temp.path()).unwrap();
2091        let cache_dir = tempfile::tempdir().unwrap();
2092        let db = cache_dir.path().join("cache.sqlite");
2093        let id = workspace_identity(&root).unwrap();
2094        create(&root.join("note.md"), "first").unwrap();
2095        let first = scan(&root, &id, &db).unwrap();
2096        fs::write(root.join("note.md"), "second").unwrap();
2097        let second = scan(&root, &id, &db).unwrap();
2098        assert_eq!(first.files[0].id, second.files[0].id);
2099        let connection = database(&db).unwrap();
2100        let version: i64 = connection
2101            .query_row(
2102                "SELECT version FROM files WHERE workspace_id=? AND path='note.md'",
2103                [&id],
2104                |row| row.get(0),
2105            )
2106            .unwrap();
2107        assert_eq!(version, 2);
2108        assert!(!root.join("index.sqlite").exists());
2109        let names = fs::read_dir(root.join(".tano"))
2110            .unwrap()
2111            .map(|entry| entry.unwrap().file_name().to_string_lossy().into_owned())
2112            .collect::<HashSet<_>>();
2113        assert_eq!(
2114            names,
2115            HashSet::from(["config.json".into(), "identities.json".into()])
2116        );
2117    }
2118    #[test]
2119    fn short_ids_are_reserved_across_restarts_and_concurrent_allocations() {
2120        let cache = tempfile::tempdir().unwrap();
2121        let db = cache.path().join("cache.sqlite");
2122        let connection = database(&db).unwrap();
2123        assert!(claim_reference_id(&connection, "workspace", "a82f91").unwrap());
2124        drop(connection);
2125        let connection = database(&db).unwrap();
2126        assert!(!claim_reference_id(&connection, "workspace", "a82f91").unwrap());
2127        assert!(claim_reference_id(&connection, "other", "a82f91").unwrap());
2128        drop(connection);
2129        let threads: Vec<_> = (0..4)
2130            .map(|_| {
2131                let db = db.clone();
2132                std::thread::spawn(move || {
2133                    (0..10)
2134                        .map(|_| reserve_reference_id(&db, "workspace", &[]).unwrap())
2135                        .collect::<Vec<_>>()
2136                })
2137            })
2138            .collect();
2139        let ids: Vec<_> = threads
2140            .into_iter()
2141            .flat_map(|thread| thread.join().unwrap())
2142            .collect();
2143        assert!(ids.iter().all(|id| is_short_id(id)));
2144        assert_eq!(
2145            ids.iter().collect::<std::collections::HashSet<_>>().len(),
2146            40
2147        );
2148        assert!(!ids.iter().any(|id| id == "a82f91"));
2149    }
2150
2151    #[test]
2152    fn deletion_keeps_document_and_imported_object_ids_reserved() {
2153        let root = tempfile::tempdir().unwrap();
2154        let cache = tempfile::tempdir().unwrap();
2155        let db = cache.path().join("cache.sqlite");
2156        fs::write(root.path().join("note.md"), "first").unwrap();
2157        fs::write(root.path().join("person.tano.json"), r#"{"id":"a82f91"}"#).unwrap();
2158        let first = scan(root.path(), "workspace", &db).unwrap();
2159        let old = first
2160            .files
2161            .iter()
2162            .find(|file| file.path == "note.md")
2163            .unwrap()
2164            .id
2165            .clone();
2166        assert!(is_short_id(&old));
2167        fs::remove_file(root.path().join("note.md")).unwrap();
2168        fs::remove_file(root.path().join("person.tano.json")).unwrap();
2169        scan(root.path(), "workspace", &db).unwrap();
2170        fs::write(root.path().join("note.md"), "new").unwrap();
2171        let next = scan(root.path(), "workspace", &db).unwrap();
2172        assert_ne!(old, next.files[0].id);
2173        let connection = database(&db).unwrap();
2174        assert!(!claim_reference_id(&connection, "workspace", &old).unwrap());
2175        assert!(!claim_reference_id(&connection, "workspace", "a82f91").unwrap());
2176    }
2177}