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