dirsql 0.3.66__tar.gz → 0.3.69__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 (135) hide show
  1. {dirsql-0.3.66 → dirsql-0.3.69}/Cargo.lock +1 -1
  2. {dirsql-0.3.66 → dirsql-0.3.69}/PKG-INFO +1 -9
  3. {dirsql-0.3.66 → dirsql-0.3.69}/packages/python/Cargo.toml +1 -1
  4. {dirsql-0.3.66 → dirsql-0.3.69}/packages/python/e2e-attestation.json +2 -2
  5. {dirsql-0.3.66 → dirsql-0.3.69}/packages/python/src/lib.rs +134 -18
  6. {dirsql-0.3.66 → dirsql-0.3.69}/packages/rust/src/cli/init.rs +42 -0
  7. {dirsql-0.3.66 → dirsql-0.3.69}/packages/rust/src/cli/mod.rs +22 -0
  8. {dirsql-0.3.66 → dirsql-0.3.69}/packages/rust/src/cli/router.rs +34 -0
  9. {dirsql-0.3.66 → dirsql-0.3.69}/pyproject.toml +7 -1
  10. {dirsql-0.3.66 → dirsql-0.3.69}/Cargo.toml +0 -0
  11. {dirsql-0.3.66 → dirsql-0.3.69}/README.md +0 -0
  12. {dirsql-0.3.66 → dirsql-0.3.69}/dirsql/__init__.py +0 -0
  13. {dirsql-0.3.66 → dirsql-0.3.69}/dirsql/_async.py +0 -0
  14. {dirsql-0.3.66 → dirsql-0.3.69}/dirsql/_dirsql.pyi +0 -0
  15. {dirsql-0.3.66 → dirsql-0.3.69}/dirsql/cli/__init__.py +0 -0
  16. {dirsql-0.3.66 → dirsql-0.3.69}/dirsql/cli/binary_path.py +0 -0
  17. {dirsql-0.3.66 → dirsql-0.3.69}/dirsql/cli/is_windows.py +0 -0
  18. {dirsql-0.3.66 → dirsql-0.3.69}/dirsql/cli/main.py +0 -0
  19. {dirsql-0.3.66 → dirsql-0.3.69}/dirsql/cli/resolve_config_extensions.py +0 -0
  20. {dirsql-0.3.66 → dirsql-0.3.69}/dirsql/py.typed +0 -0
  21. {dirsql-0.3.66 → dirsql-0.3.69}/dirsql/resolve_config_extensions.py +0 -0
  22. {dirsql-0.3.66 → dirsql-0.3.69}/dirsql/resolve_extension.py +0 -0
  23. {dirsql-0.3.66 → dirsql-0.3.69}/docs/.claude/CLAUDE.md +0 -0
  24. {dirsql-0.3.66 → dirsql-0.3.69}/docs/.vitepress/config.ts +0 -0
  25. {dirsql-0.3.66 → dirsql-0.3.69}/docs/.vitepress/theme/index.ts +0 -0
  26. {dirsql-0.3.66 → dirsql-0.3.69}/docs/.vitepress/theme/lang.ts +0 -0
  27. {dirsql-0.3.66 → dirsql-0.3.69}/docs/AGENTS.md +0 -0
  28. {dirsql-0.3.66 → dirsql-0.3.69}/docs/explanation.md +0 -0
  29. {dirsql-0.3.66 → dirsql-0.3.69}/docs/getting-started.md +0 -0
  30. {dirsql-0.3.66 → dirsql-0.3.69}/docs/howto/columns-from-paths.md +0 -0
  31. {dirsql-0.3.66 → dirsql-0.3.69}/docs/howto/define-tables.md +0 -0
  32. {dirsql-0.3.66 → dirsql-0.3.69}/docs/howto/embed.md +0 -0
  33. {dirsql-0.3.66 → dirsql-0.3.69}/docs/howto/extract-from-contents.md +0 -0
  34. {dirsql-0.3.66 → dirsql-0.3.69}/docs/howto/load-extension.md +0 -0
  35. {dirsql-0.3.66 → dirsql-0.3.69}/docs/howto/persist.md +0 -0
  36. {dirsql-0.3.66 → dirsql-0.3.69}/docs/howto/react-to-changes.md +0 -0
  37. {dirsql-0.3.66 → dirsql-0.3.69}/docs/howto/search-by-meaning.md +0 -0
  38. {dirsql-0.3.66 → dirsql-0.3.69}/docs/howto/skip-files.md +0 -0
  39. {dirsql-0.3.66 → dirsql-0.3.69}/docs/index.md +0 -0
  40. {dirsql-0.3.66 → dirsql-0.3.69}/docs/migrations.md +0 -0
  41. {dirsql-0.3.66 → dirsql-0.3.69}/docs/package.json +0 -0
  42. {dirsql-0.3.66 → dirsql-0.3.69}/docs/playwright.config.ts +0 -0
  43. {dirsql-0.3.66 → dirsql-0.3.69}/docs/pnpm-lock.yaml +0 -0
  44. {dirsql-0.3.66 → dirsql-0.3.69}/docs/pnpm-workspace.yaml +0 -0
  45. {dirsql-0.3.66 → dirsql-0.3.69}/docs/reference/cli.md +0 -0
  46. {dirsql-0.3.66 → dirsql-0.3.69}/docs/reference/columns.md +0 -0
  47. {dirsql-0.3.66 → dirsql-0.3.69}/docs/reference/config.md +0 -0
  48. {dirsql-0.3.66 → dirsql-0.3.69}/docs/reference/hooks.md +0 -0
  49. {dirsql-0.3.66 → dirsql-0.3.69}/docs/reference/http-api.md +0 -0
  50. {dirsql-0.3.66 → dirsql-0.3.69}/docs/reference/sdk.md +0 -0
  51. {dirsql-0.3.66 → dirsql-0.3.69}/docs/tests/integration/home.spec.ts +0 -0
  52. {dirsql-0.3.66 → dirsql-0.3.69}/docs/tests/integration/language-flag.spec.ts +0 -0
  53. {dirsql-0.3.66 → dirsql-0.3.69}/docs/tests/integration/sidebar.spec.ts +0 -0
  54. {dirsql-0.3.66 → dirsql-0.3.69}/docs/tests/unit/config.test.ts +0 -0
  55. {dirsql-0.3.66 → dirsql-0.3.69}/docs/tests/unit/lang.test.ts +0 -0
  56. {dirsql-0.3.66 → dirsql-0.3.69}/docs/vitest.config.ts +0 -0
  57. {dirsql-0.3.66 → dirsql-0.3.69}/packages/python/README.md +0 -0
  58. {dirsql-0.3.66 → dirsql-0.3.69}/packages/python/conftest.py +0 -0
  59. {dirsql-0.3.66 → dirsql-0.3.69}/packages/python/docs/.claude/CLAUDE.md +0 -0
  60. {dirsql-0.3.66 → dirsql-0.3.69}/packages/python/docs/.vitepress/config.ts +0 -0
  61. {dirsql-0.3.66 → dirsql-0.3.69}/packages/python/docs/.vitepress/theme/index.ts +0 -0
  62. {dirsql-0.3.66 → dirsql-0.3.69}/packages/python/docs/.vitepress/theme/lang.ts +0 -0
  63. {dirsql-0.3.66 → dirsql-0.3.69}/packages/python/docs/AGENTS.md +0 -0
  64. {dirsql-0.3.66 → dirsql-0.3.69}/packages/python/docs/explanation.md +0 -0
  65. {dirsql-0.3.66 → dirsql-0.3.69}/packages/python/docs/getting-started.md +0 -0
  66. {dirsql-0.3.66 → dirsql-0.3.69}/packages/python/docs/howto/columns-from-paths.md +0 -0
  67. {dirsql-0.3.66 → dirsql-0.3.69}/packages/python/docs/howto/define-tables.md +0 -0
  68. {dirsql-0.3.66 → dirsql-0.3.69}/packages/python/docs/howto/embed.md +0 -0
  69. {dirsql-0.3.66 → dirsql-0.3.69}/packages/python/docs/howto/extract-from-contents.md +0 -0
  70. {dirsql-0.3.66 → dirsql-0.3.69}/packages/python/docs/howto/load-extension.md +0 -0
  71. {dirsql-0.3.66 → dirsql-0.3.69}/packages/python/docs/howto/persist.md +0 -0
  72. {dirsql-0.3.66 → dirsql-0.3.69}/packages/python/docs/howto/react-to-changes.md +0 -0
  73. {dirsql-0.3.66 → dirsql-0.3.69}/packages/python/docs/howto/search-by-meaning.md +0 -0
  74. {dirsql-0.3.66 → dirsql-0.3.69}/packages/python/docs/howto/skip-files.md +0 -0
  75. {dirsql-0.3.66 → dirsql-0.3.69}/packages/python/docs/index.md +0 -0
  76. {dirsql-0.3.66 → dirsql-0.3.69}/packages/python/docs/migrations.md +0 -0
  77. {dirsql-0.3.66 → dirsql-0.3.69}/packages/python/docs/package.json +0 -0
  78. {dirsql-0.3.66 → dirsql-0.3.69}/packages/python/docs/playwright.config.ts +0 -0
  79. {dirsql-0.3.66 → dirsql-0.3.69}/packages/python/docs/pnpm-lock.yaml +0 -0
  80. {dirsql-0.3.66 → dirsql-0.3.69}/packages/python/docs/pnpm-workspace.yaml +0 -0
  81. {dirsql-0.3.66 → dirsql-0.3.69}/packages/python/docs/reference/cli.md +0 -0
  82. {dirsql-0.3.66 → dirsql-0.3.69}/packages/python/docs/reference/columns.md +0 -0
  83. {dirsql-0.3.66 → dirsql-0.3.69}/packages/python/docs/reference/config.md +0 -0
  84. {dirsql-0.3.66 → dirsql-0.3.69}/packages/python/docs/reference/hooks.md +0 -0
  85. {dirsql-0.3.66 → dirsql-0.3.69}/packages/python/docs/reference/http-api.md +0 -0
  86. {dirsql-0.3.66 → dirsql-0.3.69}/packages/python/docs/reference/sdk.md +0 -0
  87. {dirsql-0.3.66 → dirsql-0.3.69}/packages/python/docs/tests/integration/home.spec.ts +0 -0
  88. {dirsql-0.3.66 → dirsql-0.3.69}/packages/python/docs/tests/integration/language-flag.spec.ts +0 -0
  89. {dirsql-0.3.66 → dirsql-0.3.69}/packages/python/docs/tests/integration/sidebar.spec.ts +0 -0
  90. {dirsql-0.3.66 → dirsql-0.3.69}/packages/python/docs/tests/unit/config.test.ts +0 -0
  91. {dirsql-0.3.66 → dirsql-0.3.69}/packages/python/docs/tests/unit/lang.test.ts +0 -0
  92. {dirsql-0.3.66 → dirsql-0.3.69}/packages/python/docs/vitest.config.ts +0 -0
  93. {dirsql-0.3.66 → dirsql-0.3.69}/packages/python/tests/__init__.py +0 -0
  94. {dirsql-0.3.66 → dirsql-0.3.69}/packages/python/tests/binding/__init__.py +0 -0
  95. {dirsql-0.3.66 → dirsql-0.3.69}/packages/python/tests/conftest.py +0 -0
  96. {dirsql-0.3.66 → dirsql-0.3.69}/packages/python/tests/e2e/__init__.py +0 -0
  97. {dirsql-0.3.66 → dirsql-0.3.69}/packages/python/tests/integration/__init__.py +0 -0
  98. {dirsql-0.3.66 → dirsql-0.3.69}/packages/python/tests/smoke/__init__.py +0 -0
  99. {dirsql-0.3.66 → dirsql-0.3.69}/packages/rust/Cargo.toml +0 -0
  100. {dirsql-0.3.66 → dirsql-0.3.69}/packages/rust/README.md +0 -0
  101. {dirsql-0.3.66 → dirsql-0.3.69}/packages/rust/benches/db_bench.rs +0 -0
  102. {dirsql-0.3.66 → dirsql-0.3.69}/packages/rust/benches/differ_bench.rs +0 -0
  103. {dirsql-0.3.66 → dirsql-0.3.69}/packages/rust/benches/matcher_bench.rs +0 -0
  104. {dirsql-0.3.66 → dirsql-0.3.69}/packages/rust/benches/scanner_bench.rs +0 -0
  105. {dirsql-0.3.66 → dirsql-0.3.69}/packages/rust/docs/explanation.md +0 -0
  106. {dirsql-0.3.66 → dirsql-0.3.69}/packages/rust/docs/getting-started.md +0 -0
  107. {dirsql-0.3.66 → dirsql-0.3.69}/packages/rust/docs/howto/columns-from-paths.md +0 -0
  108. {dirsql-0.3.66 → dirsql-0.3.69}/packages/rust/docs/howto/define-tables.md +0 -0
  109. {dirsql-0.3.66 → dirsql-0.3.69}/packages/rust/docs/howto/embed.md +0 -0
  110. {dirsql-0.3.66 → dirsql-0.3.69}/packages/rust/docs/howto/extract-from-contents.md +0 -0
  111. {dirsql-0.3.66 → dirsql-0.3.69}/packages/rust/docs/howto/load-extension.md +0 -0
  112. {dirsql-0.3.66 → dirsql-0.3.69}/packages/rust/docs/howto/persist.md +0 -0
  113. {dirsql-0.3.66 → dirsql-0.3.69}/packages/rust/docs/howto/react-to-changes.md +0 -0
  114. {dirsql-0.3.66 → dirsql-0.3.69}/packages/rust/docs/howto/search-by-meaning.md +0 -0
  115. {dirsql-0.3.66 → dirsql-0.3.69}/packages/rust/docs/howto/skip-files.md +0 -0
  116. {dirsql-0.3.66 → dirsql-0.3.69}/packages/rust/docs/index.md +0 -0
  117. {dirsql-0.3.66 → dirsql-0.3.69}/packages/rust/docs/migrations.md +0 -0
  118. {dirsql-0.3.66 → dirsql-0.3.69}/packages/rust/docs/reference/cli.md +0 -0
  119. {dirsql-0.3.66 → dirsql-0.3.69}/packages/rust/docs/reference/columns.md +0 -0
  120. {dirsql-0.3.66 → dirsql-0.3.69}/packages/rust/docs/reference/config.md +0 -0
  121. {dirsql-0.3.66 → dirsql-0.3.69}/packages/rust/docs/reference/hooks.md +0 -0
  122. {dirsql-0.3.66 → dirsql-0.3.69}/packages/rust/docs/reference/http-api.md +0 -0
  123. {dirsql-0.3.66 → dirsql-0.3.69}/packages/rust/docs/reference/sdk.md +0 -0
  124. {dirsql-0.3.66 → dirsql-0.3.69}/packages/rust/src/bin/dirsql.rs +0 -0
  125. {dirsql-0.3.66 → dirsql-0.3.69}/packages/rust/src/cli/serialize.rs +0 -0
  126. {dirsql-0.3.66 → dirsql-0.3.69}/packages/rust/src/cli/server.rs +0 -0
  127. {dirsql-0.3.66 → dirsql-0.3.69}/packages/rust/src/command.rs +0 -0
  128. {dirsql-0.3.66 → dirsql-0.3.69}/packages/rust/src/config.rs +0 -0
  129. {dirsql-0.3.66 → dirsql-0.3.69}/packages/rust/src/db.rs +0 -0
  130. {dirsql-0.3.66 → dirsql-0.3.69}/packages/rust/src/differ.rs +0 -0
  131. {dirsql-0.3.66 → dirsql-0.3.69}/packages/rust/src/lib.rs +0 -0
  132. {dirsql-0.3.66 → dirsql-0.3.69}/packages/rust/src/matcher.rs +0 -0
  133. {dirsql-0.3.66 → dirsql-0.3.69}/packages/rust/src/persist.rs +0 -0
  134. {dirsql-0.3.66 → dirsql-0.3.69}/packages/rust/src/scanner.rs +0 -0
  135. {dirsql-0.3.66 → dirsql-0.3.69}/packages/rust/src/watcher.rs +0 -0
@@ -500,7 +500,7 @@ dependencies = [
500
500
 
501
501
  [[package]]
502
502
  name = "dirsql-py-ext"
503
- version = "0.3.66"
503
+ version = "0.3.69"
504
504
  dependencies = [
505
505
  "dirsql",
506
506
  "pyo3",
@@ -1,14 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dirsql
3
- Version: 0.3.66
4
- Requires-Dist: pytest>=8 ; extra == 'dev'
5
- Requires-Dist: pytest-describe>=2 ; extra == 'dev'
6
- Requires-Dist: pytest-asyncio>=0.23 ; extra == 'dev'
7
- Requires-Dist: pytest-cov>=5 ; extra == 'dev'
8
- Requires-Dist: ruff>=0.4 ; extra == 'dev'
9
- Requires-Dist: maturin>=1.0 ; extra == 'dev'
10
- Requires-Dist: ty==0.0.42 ; extra == 'dev'
11
- Provides-Extra: dev
3
+ Version: 0.3.69
12
4
  Summary: Ephemeral SQL index over a local directory
13
5
  Keywords: sql,filesystem,directory,sqlite,index
14
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.66"
7
+ version = "0.3.69"
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": 1783215074,
3
+ "ran_at": 1783257990,
4
4
  "exit_code": 0,
5
- "commit": "c5d752734907f27497b28b3448bad19247c3482e"
5
+ "commit": "cb8d663ecb72a808f4ab99589af16949e9449f3b"
6
6
  }
@@ -257,57 +257,93 @@ mod python {
257
257
  PyRuntimeError::new_err(e.to_string())
258
258
  }
259
259
 
260
- fn row_event_to_py(py: Python<'_>, event: &RowEvent) -> PyResult<PyRowEvent> {
261
- Ok(match event {
260
+ /// Pure, GIL-free intermediate for a row event. [`row_event_to_plain`]
261
+ /// builds it from a core [`RowEvent`] (unit-testable without a Python
262
+ /// interpreter); [`row_event_to_py`] then marshals it into the
263
+ /// Python-facing [`PyRowEvent`] (the GIL step). Splitting the two keeps the
264
+ /// variant -> action / field-selection mapping testable at the unit tier,
265
+ /// mirroring the napi binding's pure `row_event_to_js`. The value-level
266
+ /// `Row -> PyDict` conversion stays GIL-bound (binding-tier covered).
267
+ struct PlainRowEvent {
268
+ table: Option<String>,
269
+ action: &'static str,
270
+ row: Option<Row>,
271
+ old_row: Option<Row>,
272
+ error: Option<String>,
273
+ file_path: String,
274
+ }
275
+
276
+ fn row_event_to_plain(event: &RowEvent) -> PlainRowEvent {
277
+ match event {
262
278
  RowEvent::Insert {
263
279
  table,
264
280
  row,
265
281
  file_path,
266
- } => PyRowEvent {
282
+ } => PlainRowEvent {
267
283
  table: Some(table.clone()),
268
- action: "insert".to_string(),
269
- row: Some(value_row_to_py_dict(py, row)?),
284
+ action: "insert",
285
+ row: Some(row.clone()),
270
286
  old_row: None,
271
287
  error: None,
272
- file_path: Some(file_path.clone()),
288
+ file_path: file_path.clone(),
273
289
  },
274
290
  RowEvent::Update {
275
291
  table,
276
292
  old_row,
277
293
  new_row,
278
294
  file_path,
279
- } => PyRowEvent {
295
+ } => PlainRowEvent {
280
296
  table: Some(table.clone()),
281
- action: "update".to_string(),
282
- row: Some(value_row_to_py_dict(py, new_row)?),
283
- old_row: Some(value_row_to_py_dict(py, old_row)?),
297
+ action: "update",
298
+ row: Some(new_row.clone()),
299
+ old_row: Some(old_row.clone()),
284
300
  error: None,
285
- file_path: Some(file_path.clone()),
301
+ file_path: file_path.clone(),
286
302
  },
287
303
  RowEvent::Delete {
288
304
  table,
289
305
  row,
290
306
  file_path,
291
- } => PyRowEvent {
307
+ } => PlainRowEvent {
292
308
  table: Some(table.clone()),
293
- action: "delete".to_string(),
294
- row: Some(value_row_to_py_dict(py, row)?),
309
+ action: "delete",
310
+ row: Some(row.clone()),
295
311
  old_row: None,
296
312
  error: None,
297
- file_path: Some(file_path.clone()),
313
+ file_path: file_path.clone(),
298
314
  },
299
315
  RowEvent::Error {
300
316
  table,
301
317
  file_path,
302
318
  error,
303
- } => PyRowEvent {
319
+ } => PlainRowEvent {
304
320
  table: table.clone(),
305
- action: "error".to_string(),
321
+ action: "error",
306
322
  row: None,
307
323
  old_row: None,
308
324
  error: Some(error.clone()),
309
- file_path: Some(file_path.to_string_lossy().to_string()),
325
+ file_path: file_path.to_string_lossy().to_string(),
310
326
  },
327
+ }
328
+ }
329
+
330
+ fn row_event_to_py(py: Python<'_>, event: &RowEvent) -> PyResult<PyRowEvent> {
331
+ let plain = row_event_to_plain(event);
332
+ Ok(PyRowEvent {
333
+ table: plain.table,
334
+ action: plain.action.to_string(),
335
+ row: plain
336
+ .row
337
+ .as_ref()
338
+ .map(|r| value_row_to_py_dict(py, r))
339
+ .transpose()?,
340
+ old_row: plain
341
+ .old_row
342
+ .as_ref()
343
+ .map(|r| value_row_to_py_dict(py, r))
344
+ .transpose()?,
345
+ error: plain.error,
346
+ file_path: Some(plain.file_path),
311
347
  })
312
348
  }
313
349
 
@@ -381,4 +417,84 @@ mod python {
381
417
  m.add_class::<PyRowEvent>()?;
382
418
  Ok(())
383
419
  }
420
+
421
+ #[cfg(test)]
422
+ mod tests {
423
+ use super::*;
424
+
425
+ fn one_row() -> HashMap<String, Value> {
426
+ HashMap::from([("k".to_string(), Value::Integer(7))])
427
+ }
428
+
429
+ #[test]
430
+ fn plain_insert_maps_row_and_action() {
431
+ let p = row_event_to_plain(&RowEvent::Insert {
432
+ table: "t".into(),
433
+ row: one_row(),
434
+ file_path: "/f".into(),
435
+ });
436
+ assert_eq!(p.action, "insert");
437
+ assert_eq!(p.table.as_deref(), Some("t"));
438
+ assert_eq!(
439
+ p.row.as_ref().and_then(|r| r.get("k")),
440
+ Some(&Value::Integer(7))
441
+ );
442
+ assert!(p.old_row.is_none());
443
+ assert!(p.error.is_none());
444
+ assert_eq!(p.file_path, "/f");
445
+ }
446
+
447
+ #[test]
448
+ fn plain_update_carries_old_and_new() {
449
+ let mut new = one_row();
450
+ new.insert("k".to_string(), Value::Integer(9));
451
+ let p = row_event_to_plain(&RowEvent::Update {
452
+ table: "t".into(),
453
+ old_row: one_row(),
454
+ new_row: new,
455
+ file_path: "/f".into(),
456
+ });
457
+ assert_eq!(p.action, "update");
458
+ assert_eq!(
459
+ p.row.as_ref().and_then(|r| r.get("k")),
460
+ Some(&Value::Integer(9))
461
+ );
462
+ assert_eq!(
463
+ p.old_row.as_ref().and_then(|r| r.get("k")),
464
+ Some(&Value::Integer(7))
465
+ );
466
+ }
467
+
468
+ #[test]
469
+ fn plain_delete_has_row_no_old() {
470
+ let p = row_event_to_plain(&RowEvent::Delete {
471
+ table: "t".into(),
472
+ row: one_row(),
473
+ file_path: "/f".into(),
474
+ });
475
+ assert_eq!(p.action, "delete");
476
+ assert!(p.row.is_some());
477
+ assert!(p.old_row.is_none());
478
+ }
479
+
480
+ #[test]
481
+ fn plain_error_has_no_row_and_optional_table() {
482
+ let p = row_event_to_plain(&RowEvent::Error {
483
+ table: None,
484
+ file_path: std::path::PathBuf::from("/f"),
485
+ error: "boom".into(),
486
+ });
487
+ assert_eq!(p.action, "error");
488
+ assert!(p.table.is_none());
489
+ assert!(p.row.is_none());
490
+ assert!(p.old_row.is_none());
491
+ assert_eq!(p.error.as_deref(), Some("boom"));
492
+ assert_eq!(p.file_path, "/f");
493
+ }
494
+
495
+ #[test]
496
+ fn extract_error_displays_inner() {
497
+ assert_eq!(ExtractError("bad".to_string()).to_string(), "bad");
498
+ }
499
+ }
384
500
  }
@@ -166,4 +166,46 @@ mod tests {
166
166
  "got: {err:?}"
167
167
  );
168
168
  }
169
+
170
+ // The spawn / non-zero-exit / write failure arms of `run` require actually
171
+ // invoking `claude` and touching the filesystem, so they are exercised at
172
+ // the e2e tier. Their `Display` renderings, however, are pure and covered
173
+ // here (the values are constructed inline, no process or fs I/O).
174
+
175
+ #[test]
176
+ fn spawn_error_display_mentions_spawn() {
177
+ let err = InitError::Spawn(std::io::Error::new(
178
+ std::io::ErrorKind::PermissionDenied,
179
+ "denied",
180
+ ));
181
+ let msg = format!("{err}");
182
+ assert!(msg.contains("failed to spawn"), "got: {msg}");
183
+ assert!(msg.contains("denied"), "got: {msg}");
184
+ }
185
+
186
+ #[test]
187
+ fn write_error_display_names_the_path_and_source() {
188
+ let err = InitError::Write {
189
+ path: PathBuf::from("/tmp/out.toml"),
190
+ source: std::io::Error::other("disk full"),
191
+ };
192
+ let msg = format!("{err}");
193
+ assert!(msg.contains("/tmp/out.toml"), "got: {msg}");
194
+ assert!(msg.contains("disk full"), "got: {msg}");
195
+ }
196
+
197
+ #[test]
198
+ fn invalid_utf8_error_display_mentions_utf8() {
199
+ // A non-UTF8 byte sequence yields the `FromUtf8Error` that `#[from]`
200
+ // converts into `InitError::InvalidUtf8`.
201
+ let utf8_err = String::from_utf8(vec![0xff, 0xfe]).unwrap_err();
202
+ let err: InitError = utf8_err.into();
203
+ let msg = format!("{err}");
204
+ assert!(msg.contains("non-UTF8"), "got: {msg}");
205
+ }
206
+
207
+ // NOTE: `InitError::ClaudeFailed`'s `Display` is not unit-tested -- building
208
+ // it needs an `ExitStatus`, and `std::process::ExitStatus::from_raw` is
209
+ // effectful std the unit-isolation lint (`no-out-of-module-call`) forbids.
210
+ // Its rendering is covered end-to-end at the e2e tier instead.
169
211
  }
@@ -330,4 +330,26 @@ mod tests {
330
330
  assert_eq!(pq.command, "reshape {args}");
331
331
  assert_eq!(pq.config_dir, PathBuf::from("/proj"));
332
332
  }
333
+
334
+ #[test]
335
+ fn with_query_timeout_overrides_the_default() {
336
+ // The builder replaces the 30s default with the supplied timeout and
337
+ // leaves the rest of the config untouched.
338
+ let cfg = ServerConfig::bind("127.0.0.1", 8080).with_query_timeout(Duration::from_secs(5));
339
+ assert_eq!(cfg.query_timeout, Duration::from_secs(5));
340
+ assert_eq!(cfg.host, "127.0.0.1");
341
+ assert_eq!(cfg.port, 8080);
342
+ }
343
+
344
+ #[test]
345
+ fn app_state_from_string_builds_the_unavailable_arm() {
346
+ // `String -> AppState` yields the degraded arm carrying the diagnostic
347
+ // verbatim (the `/query`/`/events` 503 body). `AppState` isn't `Debug`,
348
+ // so match rather than assert on a rendering.
349
+ let state: AppState = "config failed to load".to_string().into();
350
+ match state {
351
+ AppState::Unavailable(reason) => assert_eq!(reason, "config failed to load"),
352
+ AppState::Ready(_) => panic!("String must map to the Unavailable arm"),
353
+ }
354
+ }
333
355
  }
@@ -356,4 +356,38 @@ mod tests {
356
356
  "application/json"
357
357
  );
358
358
  }
359
+
360
+ // `parse_sql_body` is the no-`pre-query` request path: it is pure (serde
361
+ // only), so it is unit-tested here directly rather than through the async
362
+ // `handle_query` handler (which needs a live server and is covered at the
363
+ // integration tier).
364
+
365
+ #[test]
366
+ fn parse_sql_body_returns_trimmed_sql() {
367
+ // Surrounding whitespace is stripped; the inner SQL is returned as-is.
368
+ let sql = parse_sql_body(r#"{"sql": " SELECT 1 "}"#).expect("valid body");
369
+ assert_eq!(sql, "SELECT 1");
370
+ }
371
+
372
+ #[test]
373
+ fn parse_sql_body_rejects_malformed_json() {
374
+ // A body that isn't JSON fails at the serde step -> 400.
375
+ let resp = parse_sql_body("not json").expect_err("malformed JSON must be rejected");
376
+ assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
377
+ }
378
+
379
+ #[test]
380
+ fn parse_sql_body_rejects_whitespace_only_sql() {
381
+ // A present-but-blank `sql` trims to empty -> 400 (the `Some(_)` arm,
382
+ // and the `false` side of the `!s.is_empty()` guard).
383
+ let resp = parse_sql_body(r#"{"sql": " "}"#).expect_err("empty sql must be rejected");
384
+ assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
385
+ }
386
+
387
+ #[test]
388
+ fn parse_sql_body_rejects_missing_sql_field() {
389
+ // Valid JSON object with no `sql` key -> the `None` arm -> 400.
390
+ let resp = parse_sql_body("{}").expect_err("missing sql must be rejected");
391
+ assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
392
+ }
359
393
  }
@@ -19,7 +19,13 @@ repository = "https://github.com/thekevinscott/dirsql"
19
19
  keywords = ["sql", "filesystem", "directory", "sqlite", "index"]
20
20
  readme = "README.md"
21
21
 
22
- [project.optional-dependencies]
22
+ # Dev/test dependencies live in a PEP 735 dependency-group (not a
23
+ # `[project.optional-dependencies]` extra) so a plain `uv sync` installs them
24
+ # by default -- the reusable testing-conventions coverage/mutation jobs run
25
+ # `uv sync` with no `--extra`, and our colocated suite needs pytest-describe /
26
+ # pytest-asyncio to collect at all. Every in-repo flow is uv-based and now uses
27
+ # plain `uv sync`; the old `pip install .[dev]` extra is intentionally dropped.
28
+ [dependency-groups]
23
29
  dev = [
24
30
  "pytest>=8",
25
31
  "pytest-describe>=2",
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