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