use std::path::PathBuf;
use super::*;
use crate::automation::watchtower::chunker::{self, extract_fragments};
use crate::storage::init_test_db;
use crate::storage::watchtower as store;
#[test]
fn matches_patterns_md_and_txt() {
let patterns = vec!["*.md".to_string(), "*.txt".to_string()];
assert!(matches_patterns(Path::new("note.md"), &patterns));
assert!(matches_patterns(Path::new("readme.txt"), &patterns));
}
#[test]
fn matches_patterns_rejects_jpg() {
let patterns = vec!["*.md".to_string(), "*.txt".to_string()];
assert!(!matches_patterns(Path::new("photo.jpg"), &patterns));
}
#[test]
fn matches_patterns_nested_path() {
let patterns = vec!["*.md".to_string()];
assert!(matches_patterns(Path::new("sub/dir/note.md"), &patterns));
}
#[test]
fn matches_patterns_empty_patterns() {
let patterns: Vec<String> = Vec::new();
assert!(!matches_patterns(Path::new("note.md"), &patterns));
}
#[test]
fn parse_front_matter_extracts_yaml() {
let content = "---\ntitle: Test Note\ntags:\n - rust\n - watchtower\n---\nBody text here.\n";
let (fm, body) = parse_front_matter(content);
assert_eq!(fm.title.as_deref(), Some("Test Note"));
assert_eq!(fm.tags.as_deref(), Some("rust,watchtower"));
assert_eq!(body, "Body text here.\n");
}
#[test]
fn parse_front_matter_no_yaml() {
let content = "Just plain text without front-matter.\n";
let (fm, body) = parse_front_matter(content);
assert!(fm.title.is_none());
assert!(fm.tags.is_none());
assert!(fm.raw_yaml.is_none());
assert_eq!(body, content);
}
#[test]
fn parse_front_matter_title_only() {
let content = "---\ntitle: Hello World\n---\nContent.\n";
let (fm, body) = parse_front_matter(content);
assert_eq!(fm.title.as_deref(), Some("Hello World"));
assert!(fm.tags.is_none());
assert_eq!(body, "Content.\n");
}
#[test]
fn parse_front_matter_tags_as_string() {
let content = "---\ntags: \"rust, testing\"\n---\nBody.\n";
let (fm, _body) = parse_front_matter(content);
assert_eq!(fm.tags.as_deref(), Some("rust, testing"));
}
#[tokio::test]
async fn ingest_file_creates_content_node() {
let pool = init_test_db().await.expect("init db");
let dir = tempfile::tempdir().unwrap();
let source_id = store::insert_source_context(&pool, "local_fs", "{}")
.await
.unwrap();
std::fs::write(dir.path().join("note.md"), "Hello from the watchtower.\n").unwrap();
let result = ingest_file(&pool, source_id, dir.path(), "note.md", false)
.await
.unwrap();
assert_eq!(result, store::UpsertResult::Inserted);
let nodes = store::get_nodes_for_source(&pool, source_id, None)
.await
.unwrap();
assert_eq!(nodes.len(), 1);
assert_eq!(nodes[0].relative_path, "note.md");
assert!(nodes[0].body_text.contains("Hello from the watchtower"));
}
#[tokio::test]
async fn ingest_file_with_front_matter() {
let pool = init_test_db().await.expect("init db");
let dir = tempfile::tempdir().unwrap();
let source_id = store::insert_source_context(&pool, "local_fs", "{}")
.await
.unwrap();
let content = "---\ntitle: My Great Note\ntags:\n - idea\n - draft\n---\nNote body.\n";
std::fs::write(dir.path().join("idea.md"), content).unwrap();
let result = ingest_file(&pool, source_id, dir.path(), "idea.md", false)
.await
.unwrap();
assert_eq!(result, store::UpsertResult::Inserted);
let nodes = store::get_nodes_for_source(&pool, source_id, None)
.await
.unwrap();
assert_eq!(nodes.len(), 1);
assert_eq!(nodes[0].title.as_deref(), Some("My Great Note"));
assert_eq!(nodes[0].tags.as_deref(), Some("idea,draft"));
assert!(nodes[0].front_matter_json.is_some());
}
#[tokio::test]
async fn ingest_file_dedup_by_hash() {
let pool = init_test_db().await.expect("init db");
let dir = tempfile::tempdir().unwrap();
let source_id = store::insert_source_context(&pool, "local_fs", "{}")
.await
.unwrap();
std::fs::write(dir.path().join("note.md"), "Static content.\n").unwrap();
let first = ingest_file(&pool, source_id, dir.path(), "note.md", false)
.await
.unwrap();
assert_eq!(first, store::UpsertResult::Inserted);
let second = ingest_file(&pool, source_id, dir.path(), "note.md", false)
.await
.unwrap();
assert_eq!(second, store::UpsertResult::Skipped);
}
#[tokio::test]
async fn ingest_file_updates_on_change() {
let pool = init_test_db().await.expect("init db");
let dir = tempfile::tempdir().unwrap();
let source_id = store::insert_source_context(&pool, "local_fs", "{}")
.await
.unwrap();
std::fs::write(dir.path().join("note.md"), "Version 1.\n").unwrap();
let first = ingest_file(&pool, source_id, dir.path(), "note.md", false)
.await
.unwrap();
assert_eq!(first, store::UpsertResult::Inserted);
std::fs::write(dir.path().join("note.md"), "Version 2 with changes.\n").unwrap();
let second = ingest_file(&pool, source_id, dir.path(), "note.md", false)
.await
.unwrap();
assert_eq!(second, store::UpsertResult::Updated);
}
#[tokio::test]
async fn ingest_file_force_bypasses_hash() {
let pool = init_test_db().await.expect("init db");
let dir = tempfile::tempdir().unwrap();
let source_id = store::insert_source_context(&pool, "local_fs", "{}")
.await
.unwrap();
std::fs::write(dir.path().join("note.md"), "Forced content.\n").unwrap();
let first = ingest_file(&pool, source_id, dir.path(), "note.md", false)
.await
.unwrap();
assert_eq!(first, store::UpsertResult::Inserted);
let second = ingest_file(&pool, source_id, dir.path(), "note.md", true)
.await
.unwrap();
assert_eq!(second, store::UpsertResult::Updated);
}
#[tokio::test]
async fn batch_ingest_summary() {
let pool = init_test_db().await.expect("init db");
let dir = tempfile::tempdir().unwrap();
let source_id = store::insert_source_context(&pool, "local_fs", "{}")
.await
.unwrap();
std::fs::write(dir.path().join("a.md"), "File A.\n").unwrap();
std::fs::write(dir.path().join("b.md"), "File B.\n").unwrap();
let paths = vec!["a.md".to_string(), "b.md".to_string()];
let summary = ingest_files(&pool, source_id, dir.path(), &paths, false).await;
assert_eq!(summary.ingested, 2);
assert_eq!(summary.skipped, 0);
let paths2 = vec![
"a.md".to_string(),
"b.md".to_string(),
"missing.md".to_string(),
];
let summary2 = ingest_files(&pool, source_id, dir.path(), &paths2, false).await;
assert_eq!(summary2.skipped, 2);
assert_eq!(summary2.errors.len(), 1);
}
#[test]
fn cooldown_prevents_reingest() {
let mut cd = CooldownSet::new(Duration::from_secs(5));
let path = PathBuf::from("/tmp/test.md");
cd.mark(path.clone());
assert!(cd.is_cooling(&path));
}
#[test]
fn cooldown_allows_unknown_path() {
let cd = CooldownSet::new(Duration::from_secs(5));
assert!(!cd.is_cooling(Path::new("/tmp/other.md")));
}
#[test]
fn cooldown_cleanup_removes_old() {
let mut cd = CooldownSet::new(Duration::from_millis(0));
let path = PathBuf::from("/tmp/test.md");
cd.mark(path.clone());
std::thread::sleep(Duration::from_millis(1));
assert!(!cd.is_cooling(&path));
cd.cleanup();
assert!(cd.entries.is_empty());
}
#[test]
fn walk_directory_finds_matching_files() {
let dir = tempfile::tempdir().unwrap();
let sub = dir.path().join("subdir");
std::fs::create_dir(&sub).unwrap();
std::fs::write(dir.path().join("root.md"), "root").unwrap();
std::fs::write(sub.join("nested.md"), "nested").unwrap();
std::fs::write(dir.path().join("image.jpg"), "binary").unwrap();
let patterns = vec!["*.md".to_string()];
let mut paths = Vec::new();
WatchtowerLoop::walk_directory(dir.path(), dir.path(), &patterns, &mut paths).unwrap();
assert_eq!(paths.len(), 2);
assert!(paths.contains(&"root.md".to_string()));
assert!(paths.contains(&"subdir/nested.md".to_string()));
}
#[test]
fn walk_directory_skips_hidden_dirs() {
let dir = tempfile::tempdir().unwrap();
let hidden = dir.path().join(".hidden");
std::fs::create_dir(&hidden).unwrap();
std::fs::write(hidden.join("secret.md"), "hidden").unwrap();
std::fs::write(dir.path().join("visible.md"), "visible").unwrap();
let patterns = vec!["*.md".to_string()];
let mut paths = Vec::new();
WatchtowerLoop::walk_directory(dir.path(), dir.path(), &patterns, &mut paths).unwrap();
assert_eq!(paths.len(), 1);
assert!(paths.contains(&"visible.md".to_string()));
}
#[tokio::test]
async fn ensure_local_fs_source_creates_and_reuses() {
let pool = init_test_db().await.expect("init db");
let id1 =
store::ensure_local_fs_source(&pool, "/home/user/notes", "{\"path\":\"/home/user/notes\"}")
.await
.unwrap();
let id2 =
store::ensure_local_fs_source(&pool, "/home/user/notes", "{\"path\":\"/home/user/notes\"}")
.await
.unwrap();
assert_eq!(id1, id2);
}
#[tokio::test]
async fn find_source_by_path_returns_none_for_missing() {
let pool = init_test_db().await.expect("init db");
let result = store::find_source_by_path(&pool, "/nonexistent")
.await
.unwrap();
assert!(result.is_none());
}
#[tokio::test]
async fn watcher_respects_cancellation() {
let pool = init_test_db().await.expect("init db");
let config = ContentSourcesConfig {
sources: Vec::new(), };
let watchtower = WatchtowerLoop::new(pool, config, Default::default(), std::env::temp_dir());
let cancel = CancellationToken::new();
let cancel_clone = cancel.clone();
let handle = tokio::spawn(async move {
watchtower.run(cancel_clone).await;
});
let result = tokio::time::timeout(Duration::from_secs(5), handle).await;
assert!(
result.is_ok(),
"Watcher should exit when no sources configured"
);
}
#[tokio::test]
async fn watcher_cancels_with_sources() {
let pool = init_test_db().await.expect("init db");
let dir = tempfile::tempdir().unwrap();
let config = ContentSourcesConfig {
sources: vec![crate::config::ContentSourceEntry {
source_type: "local_fs".to_string(),
path: Some(dir.path().to_string_lossy().to_string()),
folder_id: None,
service_account_key: None,
connection_id: None,
watch: true,
file_patterns: vec!["*.md".to_string()],
loop_back_enabled: false,
analytics_sync_enabled: false,
poll_interval_seconds: None,
enabled: None,
change_detection: "auto".to_string(),
}],
};
let watchtower = WatchtowerLoop::new(pool, config, Default::default(), std::env::temp_dir());
let cancel = CancellationToken::new();
let cancel_clone = cancel.clone();
let handle = tokio::spawn(async move {
watchtower.run(cancel_clone).await;
});
tokio::time::sleep(Duration::from_millis(200)).await;
cancel.cancel();
let result = tokio::time::timeout(Duration::from_secs(5), handle).await;
assert!(
result.is_ok(),
"Watcher should exit within timeout after cancellation"
);
}
#[tokio::test]
async fn watchtower_skips_source_without_auth() {
let pool = init_test_db().await.expect("init db");
let config = ContentSourcesConfig {
sources: vec![crate::config::ContentSourceEntry {
source_type: "google_drive".to_string(),
path: None,
folder_id: Some("folder_no_auth".to_string()),
service_account_key: None,
connection_id: None,
watch: true,
file_patterns: vec!["*.md".to_string()],
loop_back_enabled: false,
analytics_sync_enabled: false,
poll_interval_seconds: Some(300),
enabled: None,
change_detection: "auto".to_string(),
}],
};
let watchtower = WatchtowerLoop::new(
pool.clone(),
config,
Default::default(),
std::env::temp_dir(),
);
let cancel = CancellationToken::new();
let cancel_clone = cancel.clone();
let handle = tokio::spawn(async move {
watchtower.run(cancel_clone).await;
});
tokio::time::sleep(Duration::from_millis(200)).await;
cancel.cancel();
let result = tokio::time::timeout(Duration::from_secs(5), handle).await;
assert!(
result.is_ok(),
"Watcher should handle source without auth gracefully"
);
}
#[tokio::test]
async fn watchtower_handles_broken_connection() {
let pool = init_test_db().await.expect("init db");
let conn_id = store::insert_connection(&pool, "google_drive", Some("test@example.com"), None)
.await
.unwrap();
let src_id = store::insert_source_context(
&pool,
"google_drive",
&serde_json::json!({
"folder_id": "folder_broken",
"connection_id": conn_id,
})
.to_string(),
)
.await
.unwrap();
let reason = "token revoked: Token has been revoked";
store::update_source_status(&pool, src_id, "error", Some(reason))
.await
.unwrap();
store::update_connection_status(&pool, conn_id, "expired")
.await
.unwrap();
let ctx = store::get_source_context(&pool, src_id)
.await
.unwrap()
.unwrap();
assert_eq!(ctx.status, "error");
assert_eq!(ctx.error_message.as_deref(), Some(reason));
let conn = store::get_connection(&pool, conn_id)
.await
.unwrap()
.unwrap();
assert_eq!(conn.status, "expired");
}
#[tokio::test]
async fn watchtower_preserves_cursor_across_restart() {
let pool = init_test_db().await.expect("init db");
let src_id = store::ensure_google_drive_source(
&pool,
"folder_cursor",
r#"{"folder_id":"folder_cursor"}"#,
)
.await
.unwrap();
let cursor = "2026-02-28T15:30:00Z";
store::update_sync_cursor(&pool, src_id, cursor)
.await
.unwrap();
let ctx = store::get_source_context(&pool, src_id)
.await
.unwrap()
.unwrap();
assert_eq!(ctx.sync_cursor.as_deref(), Some(cursor));
}
#[tokio::test]
async fn watchtower_mixed_legacy_and_connection_sources() {
let pool = init_test_db().await.expect("init db");
let dir = tempfile::tempdir().unwrap();
let config = ContentSourcesConfig {
sources: vec![
crate::config::ContentSourceEntry {
source_type: "local_fs".to_string(),
path: Some(dir.path().to_string_lossy().to_string()),
folder_id: None,
service_account_key: None,
connection_id: None,
watch: true,
file_patterns: vec!["*.md".to_string()],
loop_back_enabled: false,
analytics_sync_enabled: false,
poll_interval_seconds: None,
enabled: None,
change_detection: "auto".to_string(),
},
crate::config::ContentSourceEntry {
source_type: "google_drive".to_string(),
path: None,
folder_id: Some("folder_mixed".to_string()),
service_account_key: None,
connection_id: None, watch: true,
file_patterns: vec!["*.md".to_string()],
loop_back_enabled: false,
analytics_sync_enabled: false,
poll_interval_seconds: Some(300),
enabled: None,
change_detection: "auto".to_string(),
},
],
};
std::fs::write(dir.path().join("note.md"), "Test content.\n").unwrap();
let watchtower = WatchtowerLoop::new(
pool.clone(),
config,
Default::default(),
std::env::temp_dir(),
);
let cancel = CancellationToken::new();
let cancel_clone = cancel.clone();
let handle = tokio::spawn(async move {
watchtower.run(cancel_clone).await;
});
tokio::time::sleep(Duration::from_millis(500)).await;
cancel.cancel();
let result = tokio::time::timeout(Duration::from_secs(5), handle).await;
assert!(result.is_ok(), "Mixed source config should not crash");
let contexts = store::get_source_contexts(&pool).await.unwrap();
assert!(
!contexts.is_empty(),
"At least the local source should be registered"
);
}
#[test]
fn extract_fragments_with_headings() {
let body = "\
Some intro text.
## Market Analysis
The market is shifting.
### Competitor Landscape
Our main competitors are...
## Product Roadmap
Next quarter plans.
";
let frags = extract_fragments(body);
assert_eq!(frags.len(), 4);
assert_eq!(frags[0].heading_path, "");
assert!(frags[0].text.contains("Some intro text"));
assert_eq!(frags[0].index, 0);
assert_eq!(frags[1].heading_path, "## Market Analysis");
assert!(frags[1].text.contains("market is shifting"));
assert_eq!(frags[1].index, 1);
assert_eq!(
frags[2].heading_path,
"## Market Analysis/### Competitor Landscape"
);
assert!(frags[2].text.contains("main competitors"));
assert_eq!(frags[2].index, 2);
assert_eq!(frags[3].heading_path, "## Product Roadmap");
assert!(frags[3].text.contains("Next quarter"));
assert_eq!(frags[3].index, 3);
}
#[test]
fn extract_fragments_no_headings() {
let body = "Just plain text\nwith multiple lines.\n";
let frags = extract_fragments(body);
assert_eq!(frags.len(), 1);
assert_eq!(frags[0].heading_path, "");
assert!(frags[0].text.contains("Just plain text"));
}
#[test]
fn extract_fragments_nested_headings_with_reset() {
let body = "\
# Title
Intro.
## Section A
Content A.
### Subsection
Deep content.
## Section B
Back to level 2.
";
let frags = extract_fragments(body);
assert_eq!(frags.len(), 4);
assert_eq!(frags[0].heading_path, "# Title");
assert!(frags[0].text.contains("Intro"));
assert_eq!(frags[1].heading_path, "# Title/## Section A");
assert_eq!(frags[2].heading_path, "# Title/## Section A/### Subsection");
assert_eq!(frags[3].heading_path, "# Title/## Section B");
}
#[test]
fn extract_fragments_empty_body() {
let frags = extract_fragments("");
assert!(frags.is_empty());
let frags = extract_fragments(" \n\n \n");
assert!(frags.is_empty());
}
#[test]
fn extract_fragments_consecutive_headings_no_body() {
let body = "## First\n## Second\nSome content.\n";
let frags = extract_fragments(body);
assert_eq!(frags.len(), 1);
assert_eq!(frags[0].heading_path, "## Second");
assert!(frags[0].text.contains("Some content"));
}
#[test]
fn extract_fragments_preserves_content() {
let body = "## Code Example\n\n```rust\nfn main() {\n # This is not a heading\n println!(\"hello\");\n}\n```\n\nEnd of section.\n";
let frags = extract_fragments(body);
assert_eq!(frags.len(), 1);
assert!(frags[0].text.contains("# This is not a heading"));
assert!(frags[0].text.contains("```rust"));
}
#[test]
fn extract_fragments_heading_inside_code_block_ignored() {
let body =
"Intro text.\n\n```\n# Not a heading\n## Also not\n```\n\n## Real Heading\n\nContent.\n";
let frags = extract_fragments(body);
assert_eq!(frags.len(), 2);
assert_eq!(frags[0].heading_path, "");
assert!(frags[0].text.contains("# Not a heading"));
assert_eq!(frags[1].heading_path, "## Real Heading");
}
#[tokio::test]
async fn chunk_node_creates_chunks() {
let pool = init_test_db().await.expect("init db");
let source_id = store::insert_source_context(&pool, "local_fs", "{}")
.await
.unwrap();
let body = "## Intro\n\nHello world.\n\n## Details\n\nMore info.\n";
let content = format!("---\ntitle: Test\n---\n{body}");
ingest_content(&pool, source_id, "test.md", &content, false)
.await
.unwrap();
let nodes = store::get_nodes_for_source(&pool, source_id, Some("pending"))
.await
.unwrap();
assert_eq!(nodes.len(), 1);
let node = &nodes[0];
let ids = chunker::chunk_node(&pool, &node.account_id, node.id, &node.body_text)
.await
.unwrap();
assert_eq!(ids.len(), 2);
let chunks = store::get_chunks_for_node(&pool, &node.account_id, node.id)
.await
.unwrap();
assert_eq!(chunks.len(), 2);
assert_eq!(chunks[0].heading_path, "## Intro");
assert_eq!(chunks[1].heading_path, "## Details");
let updated_nodes = store::get_nodes_for_source(&pool, source_id, Some("chunked"))
.await
.unwrap();
assert_eq!(updated_nodes.len(), 1);
}
#[tokio::test]
async fn chunk_node_stale_on_update_preserves_unchanged() {
let pool = init_test_db().await.expect("init db");
let source_id = store::insert_source_context(&pool, "local_fs", "{}")
.await
.unwrap();
let content_v1 =
"---\ntitle: V1\n---\n## Intro\n\nOriginal intro.\n\n## Details\n\nOriginal details.\n";
ingest_content(&pool, source_id, "note.md", content_v1, false)
.await
.unwrap();
let nodes = store::get_nodes_for_source(&pool, source_id, Some("pending"))
.await
.unwrap();
let node = &nodes[0];
let ids_v1 = chunker::chunk_node(&pool, &node.account_id, node.id, &node.body_text)
.await
.unwrap();
assert_eq!(ids_v1.len(), 2);
let content_v2 =
"---\ntitle: V2\n---\n## Intro\n\nOriginal intro.\n\n## Details\n\nUpdated details.\n";
ingest_content(&pool, source_id, "note.md", content_v2, false)
.await
.unwrap();
let nodes_v2 = store::get_nodes_for_source(&pool, source_id, Some("pending"))
.await
.unwrap();
let node_v2 = &nodes_v2[0];
let ids_v2 = chunker::chunk_node(&pool, &node_v2.account_id, node_v2.id, &node_v2.body_text)
.await
.unwrap();
assert_eq!(ids_v2.len(), 2);
assert_eq!(ids_v1[0], ids_v2[0], "Unchanged chunk should keep same ID");
assert_ne!(ids_v1[1], ids_v2[1], "Changed chunk should get new ID");
let active = store::get_chunks_for_node(&pool, &node_v2.account_id, node_v2.id)
.await
.unwrap();
assert_eq!(active.len(), 2);
}
#[test]
fn relative_path_string_simple() {
let p = Path::new("subdir/file.md");
assert_eq!(relative_path_string(p), "subdir/file.md");
}
#[test]
fn relative_path_string_single_component() {
let p = Path::new("file.txt");
assert_eq!(relative_path_string(p), "file.txt");
}
#[test]
fn relative_path_string_deep_nesting() {
let p = Path::new("a/b/c/d.md");
assert_eq!(relative_path_string(p), "a/b/c/d.md");
}
#[test]
fn ingest_summary_default() {
let s = IngestSummary::default();
assert_eq!(s.ingested, 0);
assert_eq!(s.skipped, 0);
assert!(s.errors.is_empty());
}
#[test]
fn parsed_front_matter_default() {
let fm = ParsedFrontMatter::default();
assert!(fm.title.is_none());
assert!(fm.tags.is_none());
assert!(fm.raw_yaml.is_none());
}
#[test]
fn matches_patterns_with_star_glob() {
let patterns = vec!["*.rs".to_string()];
assert!(matches_patterns(Path::new("lib.rs"), &patterns));
assert!(!matches_patterns(Path::new("lib.py"), &patterns));
}
#[test]
fn matches_patterns_no_filename() {
let patterns = vec!["*.md".to_string()];
assert!(!matches_patterns(Path::new("."), &patterns));
}
#[test]
fn parse_front_matter_invalid_yaml() {
let content = "---\n: invalid: yaml: [[\n---\nBody.\n";
let (fm, body) = parse_front_matter(content);
assert!(fm.title.is_none());
assert!(fm.tags.is_none());
assert!(fm.raw_yaml.is_some());
assert_eq!(body, "Body.\n");
}
#[test]
fn parse_front_matter_yaml_not_mapping() {
let content = "---\njust a string\n---\nBody text.\n";
let (fm, body) = parse_front_matter(content);
assert!(fm.title.is_none());
assert!(fm.tags.is_none());
assert!(fm.raw_yaml.is_some());
assert_eq!(body, "Body text.\n");
}
#[test]
fn watchtower_error_display() {
let e = WatchtowerError::Config("bad path".to_string());
assert_eq!(e.to_string(), "config error: bad path");
}
#[test]
fn ingest_summary_debug() {
let s = IngestSummary {
ingested: 3,
skipped: 1,
errors: vec!["fail".to_string()],
};
let debug = format!("{s:?}");
assert!(debug.contains("3"));
assert!(debug.contains("fail"));
}
#[test]
fn walk_directory_multiple_patterns() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("note.md"), "md").unwrap();
std::fs::write(dir.path().join("readme.txt"), "txt").unwrap();
std::fs::write(dir.path().join("image.png"), "png").unwrap();
let patterns = vec!["*.md".to_string(), "*.txt".to_string()];
let mut paths = Vec::new();
WatchtowerLoop::walk_directory(dir.path(), dir.path(), &patterns, &mut paths).unwrap();
assert_eq!(paths.len(), 2);
assert!(paths.contains(&"note.md".to_string()));
assert!(paths.contains(&"readme.txt".to_string()));
}
#[test]
fn walk_directory_empty_dir() {
let dir = tempfile::tempdir().unwrap();
let patterns = vec!["*.md".to_string()];
let mut paths = Vec::new();
WatchtowerLoop::walk_directory(dir.path(), dir.path(), &patterns, &mut paths).unwrap();
assert!(paths.is_empty());
}
#[test]
fn walk_directory_deeply_nested() {
let dir = tempfile::tempdir().unwrap();
let deep = dir.path().join("a").join("b").join("c");
std::fs::create_dir_all(&deep).unwrap();
std::fs::write(deep.join("deep.md"), "deep").unwrap();
let patterns = vec!["*.md".to_string()];
let mut paths = Vec::new();
WatchtowerLoop::walk_directory(dir.path(), dir.path(), &patterns, &mut paths).unwrap();
assert_eq!(paths.len(), 1);
assert_eq!(paths[0], "a/b/c/deep.md");
}
#[test]
fn walk_directory_nonexistent_returns_error() {
let patterns = vec!["*.md".to_string()];
let mut paths = Vec::new();
let result = WatchtowerLoop::walk_directory(
Path::new("/nonexistent_watchtower_dir_xyz"),
Path::new("/nonexistent_watchtower_dir_xyz"),
&patterns,
&mut paths,
);
assert!(result.is_err());
}
#[test]
fn cooldown_multiple_paths() {
let mut cd = CooldownSet::new(Duration::from_secs(60));
let p1 = PathBuf::from(std::env::temp_dir().join("cooldown_a.md"));
let p2 = PathBuf::from(std::env::temp_dir().join("cooldown_b.md"));
cd.mark(p1.clone());
cd.mark(p2.clone());
assert!(cd.is_cooling(&p1));
assert!(cd.is_cooling(&p2));
assert!(!cd.is_cooling(&std::env::temp_dir().join("cooldown_c.md")));
}
#[test]
fn cooldown_cleanup_retains_fresh_entries() {
let mut cd = CooldownSet::new(Duration::from_secs(60));
let p = PathBuf::from(std::env::temp_dir().join("fresh.md"));
cd.mark(p.clone());
cd.cleanup();
assert!(cd.is_cooling(&p));
assert_eq!(cd.entries.len(), 1);
}
#[test]
fn matches_patterns_multiple_extensions() {
let patterns = vec!["*.md".to_string(), "*.txt".to_string(), "*.rst".to_string()];
assert!(matches_patterns(Path::new("doc.rst"), &patterns));
assert!(!matches_patterns(Path::new("doc.html"), &patterns));
}
#[test]
fn matches_patterns_invalid_glob() {
let patterns = vec!["[invalid".to_string(), "*.md".to_string()];
assert!(matches_patterns(Path::new("note.md"), &patterns));
}
#[test]
fn matches_patterns_exact_filename() {
let patterns = vec!["README.md".to_string()];
assert!(matches_patterns(Path::new("README.md"), &patterns));
assert!(!matches_patterns(Path::new("readme.md"), &patterns));
}
#[test]
fn relative_path_string_empty() {
let p = Path::new("");
assert_eq!(relative_path_string(p), "");
}
#[test]
fn parse_front_matter_empty_tags_sequence() {
let content = "---\ntitle: Test\ntags: []\n---\nBody.\n";
let (fm, _body) = parse_front_matter(content);
assert_eq!(fm.title.as_deref(), Some("Test"));
assert!(fm.tags.is_none());
}
#[test]
fn parse_front_matter_non_string_tag_values() {
let content = "---\ntags:\n - 42\n - true\n---\nBody.\n";
let (fm, _body) = parse_front_matter(content);
assert!(fm.tags.is_none());
}
#[test]
fn parse_front_matter_extra_fields_ignored() {
let content = "---\ntitle: Note\nauthor: Alice\ndate: 2026-01-01\n---\nBody.\n";
let (fm, body) = parse_front_matter(content);
assert_eq!(fm.title.as_deref(), Some("Note"));
assert!(fm.tags.is_none());
assert!(fm.raw_yaml.is_some());
assert_eq!(body, "Body.\n");
}
#[test]
fn watchtower_error_io() {
let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file gone");
let e = WatchtowerError::from(io_err);
assert!(e.to_string().contains("IO error"));
}
#[test]
fn watchtower_error_debug() {
let e = WatchtowerError::Config("test config error".to_string());
let debug = format!("{e:?}");
assert!(debug.contains("Config"));
assert!(debug.contains("test config error"));
}
#[tokio::test]
async fn ingest_content_with_front_matter_stores_metadata() {
let pool = init_test_db().await.expect("init db");
let source_id = store::insert_source_context(&pool, "local_fs", "{}")
.await
.unwrap();
let content = "---\ntitle: Test Title\ntags:\n - alpha\n - beta\n---\nContent body here.\n";
let result = ingest_content(&pool, source_id, "test.md", content, false)
.await
.unwrap();
assert_eq!(result, store::UpsertResult::Inserted);
let nodes = store::get_nodes_for_source(&pool, source_id, None)
.await
.unwrap();
assert_eq!(nodes.len(), 1);
assert_eq!(nodes[0].title.as_deref(), Some("Test Title"));
assert_eq!(nodes[0].tags.as_deref(), Some("alpha,beta"));
}
#[tokio::test]
async fn ingest_content_plain_text_no_front_matter() {
let pool = init_test_db().await.expect("init db");
let source_id = store::insert_source_context(&pool, "local_fs", "{}")
.await
.unwrap();
let content = "Just plain text without any front-matter.\n";
let result = ingest_content(&pool, source_id, "plain.md", content, false)
.await
.unwrap();
assert_eq!(result, store::UpsertResult::Inserted);
let nodes = store::get_nodes_for_source(&pool, source_id, None)
.await
.unwrap();
assert_eq!(nodes.len(), 1);
assert!(nodes[0].title.is_none());
assert!(nodes[0].tags.is_none());
assert!(nodes[0].front_matter_json.is_none());
}
#[tokio::test]
async fn ingest_content_force_always_updates() {
let pool = init_test_db().await.expect("init db");
let source_id = store::insert_source_context(&pool, "local_fs", "{}")
.await
.unwrap();
let content = "Same content.\n";
let r1 = ingest_content(&pool, source_id, "forced.md", content, false)
.await
.unwrap();
assert_eq!(r1, store::UpsertResult::Inserted);
let r2 = ingest_content(&pool, source_id, "forced.md", content, true)
.await
.unwrap();
assert_eq!(r2, store::UpsertResult::Updated);
}