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