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
92pub 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 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(¤t) {
222 if metadata.file_type().is_symlink() {
223 return Err("为保护工作空间,不跟随符号链接".into());
224 }
225 let canonical = fs::canonicalize(¤t).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
241pub 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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(¬e, "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(¬e).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}