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