dirsql 0.3.80__tar.gz → 0.3.81__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (137) hide show
  1. {dirsql-0.3.80 → dirsql-0.3.81}/Cargo.lock +1 -1
  2. {dirsql-0.3.80 → dirsql-0.3.81}/PKG-INFO +1 -1
  3. {dirsql-0.3.80 → dirsql-0.3.81}/packages/python/Cargo.toml +1 -1
  4. {dirsql-0.3.80 → dirsql-0.3.81}/packages/python/e2e-attestation.json +2 -2
  5. {dirsql-0.3.80 → dirsql-0.3.81}/packages/rust/src/db.rs +110 -12
  6. {dirsql-0.3.80 → dirsql-0.3.81}/packages/rust/src/lib.rs +74 -4
  7. {dirsql-0.3.80 → dirsql-0.3.81}/packages/rust/src/watcher.rs +29 -0
  8. {dirsql-0.3.80 → dirsql-0.3.81}/Cargo.toml +0 -0
  9. {dirsql-0.3.80 → dirsql-0.3.81}/README.md +0 -0
  10. {dirsql-0.3.80 → dirsql-0.3.81}/dirsql/__init__.py +0 -0
  11. {dirsql-0.3.80 → dirsql-0.3.81}/dirsql/_async.py +0 -0
  12. {dirsql-0.3.80 → dirsql-0.3.81}/dirsql/_dirsql.pyi +0 -0
  13. {dirsql-0.3.80 → dirsql-0.3.81}/dirsql/cli/__init__.py +0 -0
  14. {dirsql-0.3.80 → dirsql-0.3.81}/dirsql/cli/binary_path.py +0 -0
  15. {dirsql-0.3.80 → dirsql-0.3.81}/dirsql/cli/is_windows.py +0 -0
  16. {dirsql-0.3.80 → dirsql-0.3.81}/dirsql/cli/main.py +0 -0
  17. {dirsql-0.3.80 → dirsql-0.3.81}/dirsql/cli/resolve_config_extensions.py +0 -0
  18. {dirsql-0.3.80 → dirsql-0.3.81}/dirsql/py.typed +0 -0
  19. {dirsql-0.3.80 → dirsql-0.3.81}/dirsql/resolve_config_extensions.py +0 -0
  20. {dirsql-0.3.80 → dirsql-0.3.81}/dirsql/resolve_extension.py +0 -0
  21. {dirsql-0.3.80 → dirsql-0.3.81}/docs/.claude/CLAUDE.md +0 -0
  22. {dirsql-0.3.80 → dirsql-0.3.81}/docs/.vitepress/config.ts +0 -0
  23. {dirsql-0.3.80 → dirsql-0.3.81}/docs/.vitepress/theme/index.ts +0 -0
  24. {dirsql-0.3.80 → dirsql-0.3.81}/docs/.vitepress/theme/lang.ts +0 -0
  25. {dirsql-0.3.80 → dirsql-0.3.81}/docs/AGENTS.md +0 -0
  26. {dirsql-0.3.80 → dirsql-0.3.81}/docs/explanation.md +0 -0
  27. {dirsql-0.3.80 → dirsql-0.3.81}/docs/getting-started.md +0 -0
  28. {dirsql-0.3.80 → dirsql-0.3.81}/docs/howto/columns-from-paths.md +0 -0
  29. {dirsql-0.3.80 → dirsql-0.3.81}/docs/howto/define-tables.md +0 -0
  30. {dirsql-0.3.80 → dirsql-0.3.81}/docs/howto/embed.md +0 -0
  31. {dirsql-0.3.80 → dirsql-0.3.81}/docs/howto/extract-from-contents.md +0 -0
  32. {dirsql-0.3.80 → dirsql-0.3.81}/docs/howto/load-extension.md +0 -0
  33. {dirsql-0.3.80 → dirsql-0.3.81}/docs/howto/persist.md +0 -0
  34. {dirsql-0.3.80 → dirsql-0.3.81}/docs/howto/react-to-changes.md +0 -0
  35. {dirsql-0.3.80 → dirsql-0.3.81}/docs/howto/search-by-meaning.md +0 -0
  36. {dirsql-0.3.80 → dirsql-0.3.81}/docs/howto/skip-files.md +0 -0
  37. {dirsql-0.3.80 → dirsql-0.3.81}/docs/index.md +0 -0
  38. {dirsql-0.3.80 → dirsql-0.3.81}/docs/migrations.md +0 -0
  39. {dirsql-0.3.80 → dirsql-0.3.81}/docs/package.json +0 -0
  40. {dirsql-0.3.80 → dirsql-0.3.81}/docs/playwright.config.ts +0 -0
  41. {dirsql-0.3.80 → dirsql-0.3.81}/docs/pnpm-lock.yaml +0 -0
  42. {dirsql-0.3.80 → dirsql-0.3.81}/docs/pnpm-workspace.yaml +0 -0
  43. {dirsql-0.3.80 → dirsql-0.3.81}/docs/reference/cli.md +0 -0
  44. {dirsql-0.3.80 → dirsql-0.3.81}/docs/reference/columns.md +0 -0
  45. {dirsql-0.3.80 → dirsql-0.3.81}/docs/reference/config.md +0 -0
  46. {dirsql-0.3.80 → dirsql-0.3.81}/docs/reference/hooks.md +0 -0
  47. {dirsql-0.3.80 → dirsql-0.3.81}/docs/reference/http-api.md +0 -0
  48. {dirsql-0.3.80 → dirsql-0.3.81}/docs/reference/sdk.md +0 -0
  49. {dirsql-0.3.80 → dirsql-0.3.81}/docs/tests/integration/home.spec.ts +0 -0
  50. {dirsql-0.3.80 → dirsql-0.3.81}/docs/tests/integration/language-flag.spec.ts +0 -0
  51. {dirsql-0.3.80 → dirsql-0.3.81}/docs/tests/integration/sidebar.spec.ts +0 -0
  52. {dirsql-0.3.80 → dirsql-0.3.81}/docs/tests/unit/config.test.ts +0 -0
  53. {dirsql-0.3.80 → dirsql-0.3.81}/docs/tests/unit/lang.test.ts +0 -0
  54. {dirsql-0.3.80 → dirsql-0.3.81}/docs/vitest.config.ts +0 -0
  55. {dirsql-0.3.80 → dirsql-0.3.81}/packages/python/README.md +0 -0
  56. {dirsql-0.3.80 → dirsql-0.3.81}/packages/python/conftest.py +0 -0
  57. {dirsql-0.3.80 → dirsql-0.3.81}/packages/python/docs/.claude/CLAUDE.md +0 -0
  58. {dirsql-0.3.80 → dirsql-0.3.81}/packages/python/docs/.vitepress/config.ts +0 -0
  59. {dirsql-0.3.80 → dirsql-0.3.81}/packages/python/docs/.vitepress/theme/index.ts +0 -0
  60. {dirsql-0.3.80 → dirsql-0.3.81}/packages/python/docs/.vitepress/theme/lang.ts +0 -0
  61. {dirsql-0.3.80 → dirsql-0.3.81}/packages/python/docs/AGENTS.md +0 -0
  62. {dirsql-0.3.80 → dirsql-0.3.81}/packages/python/docs/explanation.md +0 -0
  63. {dirsql-0.3.80 → dirsql-0.3.81}/packages/python/docs/getting-started.md +0 -0
  64. {dirsql-0.3.80 → dirsql-0.3.81}/packages/python/docs/howto/columns-from-paths.md +0 -0
  65. {dirsql-0.3.80 → dirsql-0.3.81}/packages/python/docs/howto/define-tables.md +0 -0
  66. {dirsql-0.3.80 → dirsql-0.3.81}/packages/python/docs/howto/embed.md +0 -0
  67. {dirsql-0.3.80 → dirsql-0.3.81}/packages/python/docs/howto/extract-from-contents.md +0 -0
  68. {dirsql-0.3.80 → dirsql-0.3.81}/packages/python/docs/howto/load-extension.md +0 -0
  69. {dirsql-0.3.80 → dirsql-0.3.81}/packages/python/docs/howto/persist.md +0 -0
  70. {dirsql-0.3.80 → dirsql-0.3.81}/packages/python/docs/howto/react-to-changes.md +0 -0
  71. {dirsql-0.3.80 → dirsql-0.3.81}/packages/python/docs/howto/search-by-meaning.md +0 -0
  72. {dirsql-0.3.80 → dirsql-0.3.81}/packages/python/docs/howto/skip-files.md +0 -0
  73. {dirsql-0.3.80 → dirsql-0.3.81}/packages/python/docs/index.md +0 -0
  74. {dirsql-0.3.80 → dirsql-0.3.81}/packages/python/docs/migrations.md +0 -0
  75. {dirsql-0.3.80 → dirsql-0.3.81}/packages/python/docs/package.json +0 -0
  76. {dirsql-0.3.80 → dirsql-0.3.81}/packages/python/docs/playwright.config.ts +0 -0
  77. {dirsql-0.3.80 → dirsql-0.3.81}/packages/python/docs/pnpm-lock.yaml +0 -0
  78. {dirsql-0.3.80 → dirsql-0.3.81}/packages/python/docs/pnpm-workspace.yaml +0 -0
  79. {dirsql-0.3.80 → dirsql-0.3.81}/packages/python/docs/reference/cli.md +0 -0
  80. {dirsql-0.3.80 → dirsql-0.3.81}/packages/python/docs/reference/columns.md +0 -0
  81. {dirsql-0.3.80 → dirsql-0.3.81}/packages/python/docs/reference/config.md +0 -0
  82. {dirsql-0.3.80 → dirsql-0.3.81}/packages/python/docs/reference/hooks.md +0 -0
  83. {dirsql-0.3.80 → dirsql-0.3.81}/packages/python/docs/reference/http-api.md +0 -0
  84. {dirsql-0.3.80 → dirsql-0.3.81}/packages/python/docs/reference/sdk.md +0 -0
  85. {dirsql-0.3.80 → dirsql-0.3.81}/packages/python/docs/tests/integration/home.spec.ts +0 -0
  86. {dirsql-0.3.80 → dirsql-0.3.81}/packages/python/docs/tests/integration/language-flag.spec.ts +0 -0
  87. {dirsql-0.3.80 → dirsql-0.3.81}/packages/python/docs/tests/integration/sidebar.spec.ts +0 -0
  88. {dirsql-0.3.80 → dirsql-0.3.81}/packages/python/docs/tests/unit/config.test.ts +0 -0
  89. {dirsql-0.3.80 → dirsql-0.3.81}/packages/python/docs/tests/unit/lang.test.ts +0 -0
  90. {dirsql-0.3.80 → dirsql-0.3.81}/packages/python/docs/vitest.config.ts +0 -0
  91. {dirsql-0.3.80 → dirsql-0.3.81}/packages/python/src/lib.rs +0 -0
  92. {dirsql-0.3.80 → dirsql-0.3.81}/packages/python/tests/__init__.py +0 -0
  93. {dirsql-0.3.80 → dirsql-0.3.81}/packages/python/tests/binding/__init__.py +0 -0
  94. {dirsql-0.3.80 → dirsql-0.3.81}/packages/python/tests/conftest.py +0 -0
  95. {dirsql-0.3.80 → dirsql-0.3.81}/packages/python/tests/e2e/__init__.py +0 -0
  96. {dirsql-0.3.80 → dirsql-0.3.81}/packages/python/tests/integration/__init__.py +0 -0
  97. {dirsql-0.3.80 → dirsql-0.3.81}/packages/python/tests/smoke/__init__.py +0 -0
  98. {dirsql-0.3.80 → dirsql-0.3.81}/packages/rust/Cargo.toml +0 -0
  99. {dirsql-0.3.80 → dirsql-0.3.81}/packages/rust/README.md +0 -0
  100. {dirsql-0.3.80 → dirsql-0.3.81}/packages/rust/benches/db_bench.rs +0 -0
  101. {dirsql-0.3.80 → dirsql-0.3.81}/packages/rust/benches/differ_bench.rs +0 -0
  102. {dirsql-0.3.80 → dirsql-0.3.81}/packages/rust/benches/matcher_bench.rs +0 -0
  103. {dirsql-0.3.80 → dirsql-0.3.81}/packages/rust/benches/scanner_bench.rs +0 -0
  104. {dirsql-0.3.80 → dirsql-0.3.81}/packages/rust/docs/explanation.md +0 -0
  105. {dirsql-0.3.80 → dirsql-0.3.81}/packages/rust/docs/getting-started.md +0 -0
  106. {dirsql-0.3.80 → dirsql-0.3.81}/packages/rust/docs/howto/columns-from-paths.md +0 -0
  107. {dirsql-0.3.80 → dirsql-0.3.81}/packages/rust/docs/howto/define-tables.md +0 -0
  108. {dirsql-0.3.80 → dirsql-0.3.81}/packages/rust/docs/howto/embed.md +0 -0
  109. {dirsql-0.3.80 → dirsql-0.3.81}/packages/rust/docs/howto/extract-from-contents.md +0 -0
  110. {dirsql-0.3.80 → dirsql-0.3.81}/packages/rust/docs/howto/load-extension.md +0 -0
  111. {dirsql-0.3.80 → dirsql-0.3.81}/packages/rust/docs/howto/persist.md +0 -0
  112. {dirsql-0.3.80 → dirsql-0.3.81}/packages/rust/docs/howto/react-to-changes.md +0 -0
  113. {dirsql-0.3.80 → dirsql-0.3.81}/packages/rust/docs/howto/search-by-meaning.md +0 -0
  114. {dirsql-0.3.80 → dirsql-0.3.81}/packages/rust/docs/howto/skip-files.md +0 -0
  115. {dirsql-0.3.80 → dirsql-0.3.81}/packages/rust/docs/index.md +0 -0
  116. {dirsql-0.3.80 → dirsql-0.3.81}/packages/rust/docs/migrations.md +0 -0
  117. {dirsql-0.3.80 → dirsql-0.3.81}/packages/rust/docs/reference/cli.md +0 -0
  118. {dirsql-0.3.80 → dirsql-0.3.81}/packages/rust/docs/reference/columns.md +0 -0
  119. {dirsql-0.3.80 → dirsql-0.3.81}/packages/rust/docs/reference/config.md +0 -0
  120. {dirsql-0.3.80 → dirsql-0.3.81}/packages/rust/docs/reference/hooks.md +0 -0
  121. {dirsql-0.3.80 → dirsql-0.3.81}/packages/rust/docs/reference/http-api.md +0 -0
  122. {dirsql-0.3.80 → dirsql-0.3.81}/packages/rust/docs/reference/sdk.md +0 -0
  123. {dirsql-0.3.80 → dirsql-0.3.81}/packages/rust/src/bin/dirsql.rs +0 -0
  124. {dirsql-0.3.80 → dirsql-0.3.81}/packages/rust/src/cli/execute.rs +0 -0
  125. {dirsql-0.3.80 → dirsql-0.3.81}/packages/rust/src/cli/init.rs +0 -0
  126. {dirsql-0.3.80 → dirsql-0.3.81}/packages/rust/src/cli/mod.rs +0 -0
  127. {dirsql-0.3.80 → dirsql-0.3.81}/packages/rust/src/cli/router.rs +0 -0
  128. {dirsql-0.3.80 → dirsql-0.3.81}/packages/rust/src/cli/serialize.rs +0 -0
  129. {dirsql-0.3.80 → dirsql-0.3.81}/packages/rust/src/cli/server.rs +0 -0
  130. {dirsql-0.3.80 → dirsql-0.3.81}/packages/rust/src/command.rs +0 -0
  131. {dirsql-0.3.80 → dirsql-0.3.81}/packages/rust/src/config.rs +0 -0
  132. {dirsql-0.3.80 → dirsql-0.3.81}/packages/rust/src/default_config.toml +0 -0
  133. {dirsql-0.3.80 → dirsql-0.3.81}/packages/rust/src/differ.rs +0 -0
  134. {dirsql-0.3.80 → dirsql-0.3.81}/packages/rust/src/matcher.rs +0 -0
  135. {dirsql-0.3.80 → dirsql-0.3.81}/packages/rust/src/persist.rs +0 -0
  136. {dirsql-0.3.80 → dirsql-0.3.81}/packages/rust/src/scanner.rs +0 -0
  137. {dirsql-0.3.80 → dirsql-0.3.81}/pyproject.toml +0 -0
@@ -500,7 +500,7 @@ dependencies = [
500
500
 
501
501
  [[package]]
502
502
  name = "dirsql-py-ext"
503
- version = "0.3.80"
503
+ version = "0.3.81"
504
504
  dependencies = [
505
505
  "dirsql",
506
506
  "pyo3",
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dirsql
3
- Version: 0.3.80
3
+ Version: 0.3.81
4
4
  Summary: Ephemeral SQL index over a local directory
5
5
  Keywords: sql,filesystem,directory,sqlite,index
6
6
  Author: Kevin Scott
@@ -4,7 +4,7 @@ name = "dirsql-py-ext"
4
4
  # pypi/maturin handler can rewrite it via `write-version` before
5
5
  # `maturin build`. `pyproject.toml` declares `dynamic = ["version"]`
6
6
  # and maturin reads this field. Mirrors `packages/rust/Cargo.toml`.
7
- version = "0.3.80"
7
+ version = "0.3.81"
8
8
  edition.workspace = true
9
9
  publish = false
10
10
  readme = "README.md"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "command": "uv run python -m pytest tests/e2e/ -x -q",
3
- "ran_at": 1783542175,
3
+ "ran_at": 1783543117,
4
4
  "exit_code": 0,
5
- "commit": "327255640a688bbd372efc17ab3f64cc59e8f651"
5
+ "commit": "c3f426b7aea69084f30366fba44e4ca1dd743184"
6
6
  }
@@ -526,19 +526,64 @@ impl rusqlite::types::ToSql for Value {
526
526
  /// dirsql constrains table names to safe unquoted identifiers via
527
527
  /// [`validate_identifier`], so the handful of forms above are the only ones
528
528
  /// that can actually resolve to a usable table.
529
- pub fn parse_table_name(ddl: &str) -> Option<String> {
530
- let upper = ddl.to_uppercase();
531
- let idx = upper.find("CREATE TABLE")?;
532
- let mut rest = ddl[idx + "CREATE TABLE".len()..].trim_start();
529
+ /// Strip leading whitespace and SQL comments (`-- ...` to end-of-line and
530
+ /// `/* ... */` blocks, repeated) from `s`. An unterminated block comment
531
+ /// consumes the rest of the input. Every returned slice starts on a char
532
+ /// boundary, so callers can index it safely.
533
+ fn skip_ws_comments(s: &str) -> &str {
534
+ let mut s = s;
535
+ loop {
536
+ let trimmed = s.trim_start();
537
+ if let Some(after) = trimmed.strip_prefix("--") {
538
+ match after.find('\n') {
539
+ Some(i) => s = &after[i + 1..],
540
+ None => return "",
541
+ }
542
+ } else if let Some(after) = trimmed.strip_prefix("/*") {
543
+ match after.find("*/") {
544
+ Some(i) => s = &after[i + 2..],
545
+ None => return "",
546
+ }
547
+ } else {
548
+ return trimmed;
549
+ }
550
+ }
551
+ }
552
+
553
+ /// If `s` begins with the ASCII keyword `kw` (case-insensitive) followed by a
554
+ /// non-identifier boundary, return the remainder after it; otherwise `None`.
555
+ /// The boundary check keeps a longer identifier (`TABLES`, `iffy`) from
556
+ /// matching a keyword prefix.
557
+ fn strip_keyword_ci<'a>(s: &'a str, kw: &str) -> Option<&'a str> {
558
+ let bytes = s.as_bytes();
559
+ if bytes.len() < kw.len() || !bytes[..kw.len()].eq_ignore_ascii_case(kw.as_bytes()) {
560
+ return None;
561
+ }
562
+ let rest = &s[kw.len()..];
563
+ match rest.chars().next() {
564
+ Some(c) if c.is_alphanumeric() || c == '_' => None,
565
+ _ => Some(rest),
566
+ }
567
+ }
533
568
 
534
- // Skip optional "IF NOT EXISTS". `.get()` avoids slicing on a non-char
535
- // boundary when the name itself begins with a multi-byte character.
536
- const IF_NOT_EXISTS: &str = "IF NOT EXISTS";
537
- if rest
538
- .get(..IF_NOT_EXISTS.len())
539
- .is_some_and(|p| p.eq_ignore_ascii_case(IF_NOT_EXISTS))
540
- {
541
- rest = rest[IF_NOT_EXISTS.len()..].trim_start();
569
+ pub fn parse_table_name(ddl: &str) -> Option<String> {
570
+ // Match `CREATE TABLE` only as the *statement head*: skip leading
571
+ // whitespace and line/block comments and scan the original `ddl` by byte
572
+ // offset (never a `to_uppercase()` copy, whose length can differ from the
573
+ // source and mis-slice on Unicode). A `-- CREATE TABLE ...` comment must
574
+ // not hijack the name.
575
+ let rest = skip_ws_comments(ddl);
576
+ let rest = strip_keyword_ci(rest, "CREATE")?;
577
+ let rest = strip_keyword_ci(skip_ws_comments(rest), "TABLE")?;
578
+ let mut rest = skip_ws_comments(rest);
579
+
580
+ // Skip optional "IF NOT EXISTS". The keyword boundary check in
581
+ // `strip_keyword_ci` keeps a table literally named e.g. `iffy` from being
582
+ // mistaken for `IF`.
583
+ if let Some(after_if) = strip_keyword_ci(rest, "IF") {
584
+ let after_not = strip_keyword_ci(skip_ws_comments(after_if), "NOT")?;
585
+ let after_exists = strip_keyword_ci(skip_ws_comments(after_not), "EXISTS")?;
586
+ rest = skip_ws_comments(after_exists);
542
587
  }
543
588
 
544
589
  // `schema.table`: keep the last dot-separated segment. Each segment may
@@ -876,6 +921,59 @@ mod tests {
876
921
  assert_eq!(parse_table_name("CREATE TABLE (id TEXT)"), None);
877
922
  }
878
923
 
924
+ #[test]
925
+ fn parse_table_name_ignores_leading_line_comment() {
926
+ assert_eq!(
927
+ parse_table_name("-- create table old\nCREATE TABLE t (x TEXT)"),
928
+ Some("t".to_string())
929
+ );
930
+ }
931
+
932
+ #[test]
933
+ fn parse_table_name_ignores_leading_block_comment() {
934
+ assert_eq!(
935
+ parse_table_name("/* CREATE TABLE old */ CREATE TABLE t (x TEXT)"),
936
+ Some("t".to_string())
937
+ );
938
+ }
939
+
940
+ #[test]
941
+ fn parse_table_name_unterminated_comment_is_none() {
942
+ // Unterminated `--` and `/* */` comments consume the rest of the input,
943
+ // leaving no statement head.
944
+ assert_eq!(parse_table_name("-- no newline, no statement"), None);
945
+ assert_eq!(parse_table_name("/* unterminated CREATE TABLE t"), None);
946
+ }
947
+
948
+ #[test]
949
+ fn parse_table_name_rejects_keyword_prefix() {
950
+ // `CREATE TABLES` must not match the `TABLE` keyword prefix.
951
+ assert_eq!(parse_table_name("CREATE TABLES foo (id TEXT)"), None);
952
+ }
953
+
954
+ #[test]
955
+ fn parse_table_name_if_prefixed_name_is_not_if_not_exists() {
956
+ // A table whose name merely starts with `if` is not `IF NOT EXISTS`.
957
+ assert_eq!(
958
+ parse_table_name("CREATE TABLE ifx (id TEXT)"),
959
+ Some("ifx".to_string())
960
+ );
961
+ }
962
+
963
+ #[test]
964
+ fn parse_table_name_unicode_in_comment_does_not_panic() {
965
+ // A case-length-changing char (`fi`) in a comment must neither hijack
966
+ // the name nor panic on a byte-index slice.
967
+ assert_eq!(
968
+ parse_table_name("-- fi\nCREATE TABLE t (x TEXT)"),
969
+ Some("t".to_string())
970
+ );
971
+ assert_eq!(
972
+ parse_table_name("/* fififi */ CREATE TABLE t (x TEXT)"),
973
+ Some("t".to_string())
974
+ );
975
+ }
976
+
879
977
  #[test]
880
978
  fn get_table_columns_returns_user_columns_only() {
881
979
  let db = Db::new().unwrap();
@@ -484,9 +484,13 @@ impl DirSQL {
484
484
  }
485
485
 
486
486
  fn handle_upsert(&self, table: &str, abs_path: &Path, rel_path: &str) -> Vec<RowEvent> {
487
- // The file may have vanished between the watcher event and now.
488
- match self.inner.fs.stat(abs_path) {
489
- Ok(_) => {}
487
+ // The path may have vanished between the watcher event and now, or be a
488
+ // directory (a `mkdir` under the root matches a `**/*` glob). Only
489
+ // regular files become rows — mirror the initial scan, which skips
490
+ // non-files.
491
+ match self.inner.fs.is_file(abs_path) {
492
+ Ok(true) => {}
493
+ Ok(false) => return Vec::new(),
490
494
  Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Vec::new(),
491
495
  Err(e) => return vec![error_event(Some(table), rel_path, e.to_string())],
492
496
  }
@@ -1215,6 +1219,8 @@ fn prepare_persist(
1215
1219
  /// tests can inject a deterministic double. Production always uses [`RealFs`].
1216
1220
  trait FileSystem: Send + Sync {
1217
1221
  fn stat(&self, path: &Path) -> std::io::Result<FileStat>;
1222
+ /// Whether `path` is a regular file. `Err(NotFound)` when it doesn't exist.
1223
+ fn is_file(&self, path: &Path) -> std::io::Result<bool>;
1218
1224
  /// BLAKE3-hash a file's contents.
1219
1225
  fn hash(&self, path: &Path) -> std::io::Result<[u8; 32]>;
1220
1226
  /// Canonicalize the watch root, falling back to the literal path.
@@ -1228,6 +1234,10 @@ impl FileSystem for RealFs {
1228
1234
  std::fs::metadata(path).map(|m| FileStat::from_metadata(&m))
1229
1235
  }
1230
1236
 
1237
+ fn is_file(&self, path: &Path) -> std::io::Result<bool> {
1238
+ std::fs::metadata(path).map(|m| m.is_file())
1239
+ }
1240
+
1231
1241
  fn hash(&self, path: &Path) -> std::io::Result<[u8; 32]> {
1232
1242
  hash_file(path)
1233
1243
  }
@@ -1822,15 +1832,19 @@ mod readonly_tests {
1822
1832
  mod internal_tests {
1823
1833
  use super::*;
1824
1834
  use std::collections::HashMap as StdHashMap;
1835
+ use std::collections::HashSet as StdHashSet;
1825
1836
  use tempfile::TempDir;
1826
1837
 
1827
1838
  /// Deterministic [`FileSystem`] double: canned [`FileStat`]s and hashes;
1828
- /// any path not present stats/hashes as `NotFound`.
1839
+ /// any path not present stats/hashes as `NotFound`. A path registered via
1840
+ /// [`with_dir`](FakeFs::with_dir) reports as a non-file; any other stat'd
1841
+ /// path reports as a regular file.
1829
1842
  #[derive(Default)]
1830
1843
  struct FakeFs {
1831
1844
  stats: StdHashMap<PathBuf, FileStat>,
1832
1845
  hashes: StdHashMap<PathBuf, [u8; 32]>,
1833
1846
  canonical_roots: StdHashMap<PathBuf, String>,
1847
+ dirs: StdHashSet<PathBuf>,
1834
1848
  }
1835
1849
 
1836
1850
  impl FakeFs {
@@ -1840,6 +1854,12 @@ mod internal_tests {
1840
1854
  fs
1841
1855
  }
1842
1856
 
1857
+ /// Register `path` as an existing non-file (a directory).
1858
+ fn with_dir(mut self, path: impl Into<PathBuf>) -> Self {
1859
+ self.dirs.insert(path.into());
1860
+ self
1861
+ }
1862
+
1843
1863
  fn set_hash(&mut self, path: impl Into<PathBuf>, hash: [u8; 32]) {
1844
1864
  self.hashes.insert(path.into(), hash);
1845
1865
  }
@@ -1862,6 +1882,19 @@ mod internal_tests {
1862
1882
  })
1863
1883
  }
1864
1884
 
1885
+ fn is_file(&self, path: &Path) -> std::io::Result<bool> {
1886
+ if self.dirs.contains(path) {
1887
+ return Ok(false);
1888
+ }
1889
+ if self.stats.contains_key(path) {
1890
+ return Ok(true);
1891
+ }
1892
+ Err(std::io::Error::new(
1893
+ std::io::ErrorKind::NotFound,
1894
+ "fake: no such file",
1895
+ ))
1896
+ }
1897
+
1865
1898
  fn hash(&self, path: &Path) -> std::io::Result<[u8; 32]> {
1866
1899
  self.hashes.get(path).copied().ok_or_else(|| {
1867
1900
  std::io::Error::new(std::io::ErrorKind::NotFound, "fake: no such file")
@@ -2208,6 +2241,15 @@ mod internal_tests {
2208
2241
  fs.hash(&missing).is_err(),
2209
2242
  "hash of a missing path must error"
2210
2243
  );
2244
+
2245
+ assert!(
2246
+ !fs.is_file(dir.path()).unwrap(),
2247
+ "a directory must report is_file=false"
2248
+ );
2249
+ assert!(
2250
+ fs.is_file(&missing).is_err(),
2251
+ "is_file of a missing path must error"
2252
+ );
2211
2253
  // A nonexistent path can't canonicalize, so the literal fallback runs.
2212
2254
  assert_eq!(
2213
2255
  fs.canonical_root(&missing),
@@ -2371,6 +2413,34 @@ mod internal_tests {
2371
2413
  assert!(dbg.contains("insert forbidden"), "got: {dbg}");
2372
2414
  }
2373
2415
 
2416
+ #[test]
2417
+ fn handle_upsert_skips_directory() {
2418
+ // A `mkdir` under the root matches a `**/*` glob, but a directory must
2419
+ // not become a row — mirror the initial scan's non-file skip.
2420
+ let dir = TempDir::new().unwrap();
2421
+ let subdir = dir.path().join("subdir");
2422
+ let fake = FakeFs::default().with_dir(subdir.clone());
2423
+ let db = DirSQL::with_ignore_and_fs(
2424
+ dir.path(),
2425
+ vec![Table::new("CREATE TABLE files (name TEXT)", "**/*", |_| {
2426
+ vec![Row::from_iter([(
2427
+ "name".to_string(),
2428
+ Value::Text("x".into()),
2429
+ )])]
2430
+ })],
2431
+ Vec::<String>::new(),
2432
+ Arc::new(fake),
2433
+ )
2434
+ .unwrap();
2435
+
2436
+ let events = db.handle_upsert("files", &subdir, "subdir");
2437
+ assert!(events.is_empty(), "a directory must not produce row events");
2438
+ assert!(
2439
+ db.query("SELECT * FROM files").unwrap().is_empty(),
2440
+ "a directory must not insert a row"
2441
+ );
2442
+ }
2443
+
2374
2444
  #[test]
2375
2445
  fn handle_upsert_returns_empty_when_file_vanished() {
2376
2446
  let (dir, db, _abs, _rel) = upsert_fixture();
@@ -1,3 +1,4 @@
1
+ use notify::event::{ModifyKind, RenameMode};
1
2
  use notify::{
2
3
  Config, Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher as NotifyWatcher,
3
4
  };
@@ -72,6 +73,12 @@ fn translate_event(event: &Event) -> Vec<FileEvent> {
72
73
  for path in &event.paths {
73
74
  let fe = match event.kind {
74
75
  EventKind::Create(_) => Some(FileEvent::Created(path.clone())),
76
+ // A rename OUT of the tree (inotify `IN_MOVED_FROM`) leaves no file
77
+ // behind, so it is a removal — otherwise the moved-away file's rows
78
+ // would persist.
79
+ EventKind::Modify(ModifyKind::Name(RenameMode::From)) => {
80
+ Some(FileEvent::Deleted(path.clone()))
81
+ }
75
82
  EventKind::Modify(_) => Some(FileEvent::Modified(path.clone())),
76
83
  EventKind::Remove(_) => Some(FileEvent::Deleted(path.clone())),
77
84
  _ => None,
@@ -117,6 +124,17 @@ fn remove_event(paths: Vec<PathBuf>) -> Event {
117
124
  }
118
125
  }
119
126
 
127
+ #[cfg(test)]
128
+ fn rename_from_event(paths: Vec<PathBuf>) -> Event {
129
+ Event {
130
+ kind: EventKind::Modify(notify::event::ModifyKind::Name(
131
+ notify::event::RenameMode::From,
132
+ )),
133
+ paths,
134
+ attrs: Default::default(),
135
+ }
136
+ }
137
+
120
138
  #[cfg(test)]
121
139
  fn access_event(paths: Vec<PathBuf>) -> Event {
122
140
  Event {
@@ -167,6 +185,17 @@ mod tests {
167
185
  );
168
186
  }
169
187
 
188
+ #[test]
189
+ fn translate_event_maps_rename_from_to_delete() {
190
+ // inotify emits `Modify(Name(From))` when a file is renamed OUT of the
191
+ // watched tree; it must be treated as a removal so its rows are deleted.
192
+ let results = translate_event(&rename_from_event(vec![PathBuf::from("/tmp/moved.txt")]));
193
+ assert_eq!(
194
+ results,
195
+ vec![FileEvent::Deleted(PathBuf::from("/tmp/moved.txt"))]
196
+ );
197
+ }
198
+
170
199
  #[test]
171
200
  fn translate_event_multiple_paths() {
172
201
  let results = translate_event(&create_event(vec![
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes