dirsql 0.3.57__tar.gz → 0.3.59__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 (124) hide show
  1. {dirsql-0.3.57 → dirsql-0.3.59}/Cargo.lock +1 -1
  2. {dirsql-0.3.57 → dirsql-0.3.59}/PKG-INFO +1 -1
  3. {dirsql-0.3.57 → dirsql-0.3.59}/docs/cli/config.md +23 -5
  4. {dirsql-0.3.57 → dirsql-0.3.59}/packages/python/Cargo.toml +1 -1
  5. {dirsql-0.3.57/packages/rust → dirsql-0.3.59/packages/python}/docs/cli/config.md +23 -5
  6. {dirsql-0.3.57 → dirsql-0.3.59}/packages/python/e2e-attestation.json +2 -2
  7. {dirsql-0.3.57/packages/python → dirsql-0.3.59/packages/rust}/docs/cli/config.md +23 -5
  8. {dirsql-0.3.57 → dirsql-0.3.59}/packages/rust/src/bin/dirsql.rs +14 -4
  9. {dirsql-0.3.57 → dirsql-0.3.59}/packages/rust/src/cli/init.rs +17 -0
  10. {dirsql-0.3.57 → dirsql-0.3.59}/packages/rust/src/cli/mod.rs +45 -4
  11. {dirsql-0.3.57 → dirsql-0.3.59}/packages/rust/src/cli/router.rs +10 -15
  12. {dirsql-0.3.57 → dirsql-0.3.59}/packages/rust/src/cli/server.rs +29 -0
  13. {dirsql-0.3.57 → dirsql-0.3.59}/packages/rust/src/command.rs +104 -0
  14. {dirsql-0.3.57 → dirsql-0.3.59}/packages/rust/src/config.rs +118 -15
  15. {dirsql-0.3.57 → dirsql-0.3.59}/packages/rust/src/lib.rs +585 -9
  16. {dirsql-0.3.57 → dirsql-0.3.59}/packages/rust/src/persist.rs +70 -0
  17. {dirsql-0.3.57 → dirsql-0.3.59}/Cargo.toml +0 -0
  18. {dirsql-0.3.57 → dirsql-0.3.59}/README.md +0 -0
  19. {dirsql-0.3.57 → dirsql-0.3.59}/dirsql/__init__.py +0 -0
  20. {dirsql-0.3.57 → dirsql-0.3.59}/dirsql/_async.py +0 -0
  21. {dirsql-0.3.57 → dirsql-0.3.59}/dirsql/_dirsql.pyi +0 -0
  22. {dirsql-0.3.57 → dirsql-0.3.59}/dirsql/cli/__init__.py +0 -0
  23. {dirsql-0.3.57 → dirsql-0.3.59}/dirsql/cli/binary_path.py +0 -0
  24. {dirsql-0.3.57 → dirsql-0.3.59}/dirsql/cli/interpret/__init__.py +0 -0
  25. {dirsql-0.3.57 → dirsql-0.3.59}/dirsql/cli/is_windows.py +0 -0
  26. {dirsql-0.3.57 → dirsql-0.3.59}/dirsql/cli/main.py +0 -0
  27. {dirsql-0.3.57 → dirsql-0.3.59}/dirsql/cli/resolve_config_extensions.py +0 -0
  28. {dirsql-0.3.57 → dirsql-0.3.59}/dirsql/py.typed +0 -0
  29. {dirsql-0.3.57 → dirsql-0.3.59}/dirsql/resolve_config_extensions.py +0 -0
  30. {dirsql-0.3.57 → dirsql-0.3.59}/dirsql/resolve_extension.py +0 -0
  31. {dirsql-0.3.57 → dirsql-0.3.59}/docs/.claude/CLAUDE.md +0 -0
  32. {dirsql-0.3.57 → dirsql-0.3.59}/docs/.vitepress/config.ts +0 -0
  33. {dirsql-0.3.57 → dirsql-0.3.59}/docs/.vitepress/theme/index.ts +0 -0
  34. {dirsql-0.3.57 → dirsql-0.3.59}/docs/.vitepress/theme/lang.ts +0 -0
  35. {dirsql-0.3.57 → dirsql-0.3.59}/docs/AGENTS.md +0 -0
  36. {dirsql-0.3.57 → dirsql-0.3.59}/docs/api/index.md +0 -0
  37. {dirsql-0.3.57 → dirsql-0.3.59}/docs/cli/http-api.md +0 -0
  38. {dirsql-0.3.57 → dirsql-0.3.59}/docs/cli/index.md +0 -0
  39. {dirsql-0.3.57 → dirsql-0.3.59}/docs/cli/init.md +0 -0
  40. {dirsql-0.3.57 → dirsql-0.3.59}/docs/cli/server.md +0 -0
  41. {dirsql-0.3.57 → dirsql-0.3.59}/docs/getting-started.md +0 -0
  42. {dirsql-0.3.57 → dirsql-0.3.59}/docs/guide/async.md +0 -0
  43. {dirsql-0.3.57 → dirsql-0.3.59}/docs/guide/crdt.md +0 -0
  44. {dirsql-0.3.57 → dirsql-0.3.59}/docs/guide/persistence.md +0 -0
  45. {dirsql-0.3.57 → dirsql-0.3.59}/docs/guide/querying.md +0 -0
  46. {dirsql-0.3.57 → dirsql-0.3.59}/docs/guide/tables.md +0 -0
  47. {dirsql-0.3.57 → dirsql-0.3.59}/docs/guide/watching.md +0 -0
  48. {dirsql-0.3.57 → dirsql-0.3.59}/docs/index.md +0 -0
  49. {dirsql-0.3.57 → dirsql-0.3.59}/docs/migrations.md +0 -0
  50. {dirsql-0.3.57 → dirsql-0.3.59}/docs/package.json +0 -0
  51. {dirsql-0.3.57 → dirsql-0.3.59}/docs/playwright.config.ts +0 -0
  52. {dirsql-0.3.57 → dirsql-0.3.59}/docs/pnpm-lock.yaml +0 -0
  53. {dirsql-0.3.57 → dirsql-0.3.59}/docs/pnpm-workspace.yaml +0 -0
  54. {dirsql-0.3.57 → dirsql-0.3.59}/docs/tests/integration/home.spec.ts +0 -0
  55. {dirsql-0.3.57 → dirsql-0.3.59}/docs/tests/integration/language-flag.spec.ts +0 -0
  56. {dirsql-0.3.57 → dirsql-0.3.59}/docs/tests/integration/sidebar.spec.ts +0 -0
  57. {dirsql-0.3.57 → dirsql-0.3.59}/docs/tests/unit/config.test.ts +0 -0
  58. {dirsql-0.3.57 → dirsql-0.3.59}/docs/tests/unit/lang.test.ts +0 -0
  59. {dirsql-0.3.57 → dirsql-0.3.59}/docs/vitest.config.ts +0 -0
  60. {dirsql-0.3.57 → dirsql-0.3.59}/packages/python/README.md +0 -0
  61. {dirsql-0.3.57 → dirsql-0.3.59}/packages/python/conftest.py +0 -0
  62. {dirsql-0.3.57 → dirsql-0.3.59}/packages/python/docs/.claude/CLAUDE.md +0 -0
  63. {dirsql-0.3.57 → dirsql-0.3.59}/packages/python/docs/.vitepress/config.ts +0 -0
  64. {dirsql-0.3.57 → dirsql-0.3.59}/packages/python/docs/.vitepress/theme/index.ts +0 -0
  65. {dirsql-0.3.57 → dirsql-0.3.59}/packages/python/docs/.vitepress/theme/lang.ts +0 -0
  66. {dirsql-0.3.57 → dirsql-0.3.59}/packages/python/docs/AGENTS.md +0 -0
  67. {dirsql-0.3.57 → dirsql-0.3.59}/packages/python/docs/api/index.md +0 -0
  68. {dirsql-0.3.57 → dirsql-0.3.59}/packages/python/docs/cli/http-api.md +0 -0
  69. {dirsql-0.3.57 → dirsql-0.3.59}/packages/python/docs/cli/index.md +0 -0
  70. {dirsql-0.3.57 → dirsql-0.3.59}/packages/python/docs/cli/init.md +0 -0
  71. {dirsql-0.3.57 → dirsql-0.3.59}/packages/python/docs/cli/server.md +0 -0
  72. {dirsql-0.3.57 → dirsql-0.3.59}/packages/python/docs/getting-started.md +0 -0
  73. {dirsql-0.3.57 → dirsql-0.3.59}/packages/python/docs/guide/async.md +0 -0
  74. {dirsql-0.3.57 → dirsql-0.3.59}/packages/python/docs/guide/crdt.md +0 -0
  75. {dirsql-0.3.57 → dirsql-0.3.59}/packages/python/docs/guide/persistence.md +0 -0
  76. {dirsql-0.3.57 → dirsql-0.3.59}/packages/python/docs/guide/querying.md +0 -0
  77. {dirsql-0.3.57 → dirsql-0.3.59}/packages/python/docs/guide/tables.md +0 -0
  78. {dirsql-0.3.57 → dirsql-0.3.59}/packages/python/docs/guide/watching.md +0 -0
  79. {dirsql-0.3.57 → dirsql-0.3.59}/packages/python/docs/index.md +0 -0
  80. {dirsql-0.3.57 → dirsql-0.3.59}/packages/python/docs/migrations.md +0 -0
  81. {dirsql-0.3.57 → dirsql-0.3.59}/packages/python/docs/package.json +0 -0
  82. {dirsql-0.3.57 → dirsql-0.3.59}/packages/python/docs/playwright.config.ts +0 -0
  83. {dirsql-0.3.57 → dirsql-0.3.59}/packages/python/docs/pnpm-lock.yaml +0 -0
  84. {dirsql-0.3.57 → dirsql-0.3.59}/packages/python/docs/pnpm-workspace.yaml +0 -0
  85. {dirsql-0.3.57 → dirsql-0.3.59}/packages/python/docs/tests/integration/home.spec.ts +0 -0
  86. {dirsql-0.3.57 → dirsql-0.3.59}/packages/python/docs/tests/integration/language-flag.spec.ts +0 -0
  87. {dirsql-0.3.57 → dirsql-0.3.59}/packages/python/docs/tests/integration/sidebar.spec.ts +0 -0
  88. {dirsql-0.3.57 → dirsql-0.3.59}/packages/python/docs/tests/unit/config.test.ts +0 -0
  89. {dirsql-0.3.57 → dirsql-0.3.59}/packages/python/docs/tests/unit/lang.test.ts +0 -0
  90. {dirsql-0.3.57 → dirsql-0.3.59}/packages/python/docs/vitest.config.ts +0 -0
  91. {dirsql-0.3.57 → dirsql-0.3.59}/packages/python/src/lib.rs +0 -0
  92. {dirsql-0.3.57 → dirsql-0.3.59}/packages/python/tests/__init__.py +0 -0
  93. {dirsql-0.3.57 → dirsql-0.3.59}/packages/python/tests/binding/__init__.py +0 -0
  94. {dirsql-0.3.57 → dirsql-0.3.59}/packages/python/tests/conftest.py +0 -0
  95. {dirsql-0.3.57 → dirsql-0.3.59}/packages/python/tests/e2e/__init__.py +0 -0
  96. {dirsql-0.3.57 → dirsql-0.3.59}/packages/python/tests/integration/__init__.py +0 -0
  97. {dirsql-0.3.57 → dirsql-0.3.59}/packages/python/tests/smoke/__init__.py +0 -0
  98. {dirsql-0.3.57 → dirsql-0.3.59}/packages/rust/Cargo.toml +0 -0
  99. {dirsql-0.3.57 → dirsql-0.3.59}/packages/rust/README.md +0 -0
  100. {dirsql-0.3.57 → dirsql-0.3.59}/packages/rust/benches/db_bench.rs +0 -0
  101. {dirsql-0.3.57 → dirsql-0.3.59}/packages/rust/benches/differ_bench.rs +0 -0
  102. {dirsql-0.3.57 → dirsql-0.3.59}/packages/rust/benches/matcher_bench.rs +0 -0
  103. {dirsql-0.3.57 → dirsql-0.3.59}/packages/rust/benches/scanner_bench.rs +0 -0
  104. {dirsql-0.3.57 → dirsql-0.3.59}/packages/rust/docs/api/index.md +0 -0
  105. {dirsql-0.3.57 → dirsql-0.3.59}/packages/rust/docs/cli/http-api.md +0 -0
  106. {dirsql-0.3.57 → dirsql-0.3.59}/packages/rust/docs/cli/index.md +0 -0
  107. {dirsql-0.3.57 → dirsql-0.3.59}/packages/rust/docs/cli/init.md +0 -0
  108. {dirsql-0.3.57 → dirsql-0.3.59}/packages/rust/docs/cli/server.md +0 -0
  109. {dirsql-0.3.57 → dirsql-0.3.59}/packages/rust/docs/getting-started.md +0 -0
  110. {dirsql-0.3.57 → dirsql-0.3.59}/packages/rust/docs/guide/async.md +0 -0
  111. {dirsql-0.3.57 → dirsql-0.3.59}/packages/rust/docs/guide/crdt.md +0 -0
  112. {dirsql-0.3.57 → dirsql-0.3.59}/packages/rust/docs/guide/persistence.md +0 -0
  113. {dirsql-0.3.57 → dirsql-0.3.59}/packages/rust/docs/guide/querying.md +0 -0
  114. {dirsql-0.3.57 → dirsql-0.3.59}/packages/rust/docs/guide/tables.md +0 -0
  115. {dirsql-0.3.57 → dirsql-0.3.59}/packages/rust/docs/guide/watching.md +0 -0
  116. {dirsql-0.3.57 → dirsql-0.3.59}/packages/rust/docs/index.md +0 -0
  117. {dirsql-0.3.57 → dirsql-0.3.59}/packages/rust/docs/migrations.md +0 -0
  118. {dirsql-0.3.57 → dirsql-0.3.59}/packages/rust/src/cli/serialize.rs +0 -0
  119. {dirsql-0.3.57 → dirsql-0.3.59}/packages/rust/src/db.rs +0 -0
  120. {dirsql-0.3.57 → dirsql-0.3.59}/packages/rust/src/differ.rs +0 -0
  121. {dirsql-0.3.57 → dirsql-0.3.59}/packages/rust/src/matcher.rs +0 -0
  122. {dirsql-0.3.57 → dirsql-0.3.59}/packages/rust/src/scanner.rs +0 -0
  123. {dirsql-0.3.57 → dirsql-0.3.59}/packages/rust/src/watcher.rs +0 -0
  124. {dirsql-0.3.57 → dirsql-0.3.59}/pyproject.toml +0 -0
@@ -500,7 +500,7 @@ dependencies = [
500
500
 
501
501
  [[package]]
502
502
  name = "dirsql-py-ext"
503
- version = "0.3.57"
503
+ version = "0.3.59"
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.57
3
+ Version: 0.3.59
4
4
  Requires-Dist: pytest>=8 ; extra == 'dev'
5
5
  Requires-Dist: pytest-describe>=2 ; extra == 'dev'
6
6
  Requires-Dist: pytest-asyncio>=0.23 ; extra == 'dev'
@@ -244,6 +244,13 @@ file is skipped (it contributes no rows) and a one-line warning naming the file
244
244
  and the error is written to stderr. One bad file never aborts the scan; the
245
245
  other files' rows are indexed normally.
246
246
 
247
+ **Timeout.** Each run is bounded by the default 30-second timeout. When a
248
+ command legitimately needs longer — it downloads a model on first run, or calls
249
+ a rate-limited API — raise the bound with the global
250
+ [`[dirsql].hook-timeout`](#command-execution) key (positive whole seconds). A
251
+ run exceeding the bound is killed and handled with the per-file error isolation
252
+ above (the file is skipped with a stderr warning).
253
+
247
254
  See [Command execution](#command-execution) for the full contract (argv
248
255
  splitting, injection safety, cwd, environment, timeout, and output framing).
249
256
 
@@ -279,7 +286,8 @@ key and the `{"sql": …}` contract returns.
279
286
  **On failure** — a non-zero exit, a timeout, or a spawn error — the request
280
287
  returns `500 Internal Server Error` with the command's stderr tail in the JSON
281
288
  `error` body. The command runs in the config file's directory and is bounded by
282
- a fixed **30-second** timeout.
289
+ a **30-second** timeout by default; a slow translator (e.g. LLM-backed) can
290
+ raise it with the global [`[dirsql].hook-timeout`](#command-execution) key.
283
291
 
284
292
  #### The hook owns SQL safety
285
293
 
@@ -361,7 +369,8 @@ returns.
361
369
  **On failure** — a non-zero exit, a timeout, or a spawn error — the request
362
370
  returns `500 Internal Server Error` with the command's stderr tail in the JSON
363
371
  `error` body. The command runs in the config file's directory and is bounded by
364
- a fixed **30-second** timeout.
372
+ a **30-second** timeout by default; raise it with the global
373
+ [`[dirsql].hook-timeout`](#command-execution) key.
365
374
 
366
375
  See [Command execution](#command-execution) for the full contract (argv
367
376
  splitting, injection safety, cwd, environment, timeout, and output framing).
@@ -409,9 +418,18 @@ Config keys that run an external command — today `on-file`, `pre-query`, and
409
418
  - **Output framing.** The command's result is the **last non-empty line of
410
419
  stdout**; any log/chatter lines above it are ignored. stderr is never data —
411
420
  it is captured only to enrich error messages.
412
- - **Timeout.** Each command run is bounded by a fixed **30-second** timeout (no
413
- per-table override yet); a command that exceeds it is killed and treated as a
414
- failure.
421
+ - **Timeout.** Each command run is bounded by a **30-second** timeout by
422
+ default; a command that exceeds it is killed and treated as a failure. Raise
423
+ (or tighten) it for **all** hooks — `on-file`, `pre-query`, and `post-query`
424
+ alike — with a single global `[dirsql].hook-timeout` key, in positive whole
425
+ seconds:
426
+
427
+ ```toml
428
+ [dirsql]
429
+ hook-timeout = 300
430
+ ```
431
+
432
+ Zero and negative values are rejected as a config error.
415
433
  - **Errors.** A non-zero exit, a timeout, a spawn failure, or output that does
416
434
  not parse as expected is a per-file failure: the file is skipped with a
417
435
  stderr warning and the scan continues.
@@ -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.57"
7
+ version = "0.3.59"
8
8
  edition.workspace = true
9
9
  publish = false
10
10
  readme = "README.md"
@@ -244,6 +244,13 @@ file is skipped (it contributes no rows) and a one-line warning naming the file
244
244
  and the error is written to stderr. One bad file never aborts the scan; the
245
245
  other files' rows are indexed normally.
246
246
 
247
+ **Timeout.** Each run is bounded by the default 30-second timeout. When a
248
+ command legitimately needs longer — it downloads a model on first run, or calls
249
+ a rate-limited API — raise the bound with the global
250
+ [`[dirsql].hook-timeout`](#command-execution) key (positive whole seconds). A
251
+ run exceeding the bound is killed and handled with the per-file error isolation
252
+ above (the file is skipped with a stderr warning).
253
+
247
254
  See [Command execution](#command-execution) for the full contract (argv
248
255
  splitting, injection safety, cwd, environment, timeout, and output framing).
249
256
 
@@ -279,7 +286,8 @@ key and the `{"sql": …}` contract returns.
279
286
  **On failure** — a non-zero exit, a timeout, or a spawn error — the request
280
287
  returns `500 Internal Server Error` with the command's stderr tail in the JSON
281
288
  `error` body. The command runs in the config file's directory and is bounded by
282
- a fixed **30-second** timeout.
289
+ a **30-second** timeout by default; a slow translator (e.g. LLM-backed) can
290
+ raise it with the global [`[dirsql].hook-timeout`](#command-execution) key.
283
291
 
284
292
  #### The hook owns SQL safety
285
293
 
@@ -361,7 +369,8 @@ returns.
361
369
  **On failure** — a non-zero exit, a timeout, or a spawn error — the request
362
370
  returns `500 Internal Server Error` with the command's stderr tail in the JSON
363
371
  `error` body. The command runs in the config file's directory and is bounded by
364
- a fixed **30-second** timeout.
372
+ a **30-second** timeout by default; raise it with the global
373
+ [`[dirsql].hook-timeout`](#command-execution) key.
365
374
 
366
375
  See [Command execution](#command-execution) for the full contract (argv
367
376
  splitting, injection safety, cwd, environment, timeout, and output framing).
@@ -409,9 +418,18 @@ Config keys that run an external command — today `on-file`, `pre-query`, and
409
418
  - **Output framing.** The command's result is the **last non-empty line of
410
419
  stdout**; any log/chatter lines above it are ignored. stderr is never data —
411
420
  it is captured only to enrich error messages.
412
- - **Timeout.** Each command run is bounded by a fixed **30-second** timeout (no
413
- per-table override yet); a command that exceeds it is killed and treated as a
414
- failure.
421
+ - **Timeout.** Each command run is bounded by a **30-second** timeout by
422
+ default; a command that exceeds it is killed and treated as a failure. Raise
423
+ (or tighten) it for **all** hooks — `on-file`, `pre-query`, and `post-query`
424
+ alike — with a single global `[dirsql].hook-timeout` key, in positive whole
425
+ seconds:
426
+
427
+ ```toml
428
+ [dirsql]
429
+ hook-timeout = 300
430
+ ```
431
+
432
+ Zero and negative values are rejected as a config error.
415
433
  - **Errors.** A non-zero exit, a timeout, a spawn failure, or output that does
416
434
  not parse as expected is a per-file failure: the file is skipped with a
417
435
  stderr warning and the scan continues.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "command": "uv run python -m pytest tests/e2e/ -x -q",
3
- "ran_at": 1783105847,
3
+ "ran_at": 1783120355,
4
4
  "exit_code": 0,
5
- "commit": "d94d40b0448b03171f2e8ffd60635575da1353d5"
5
+ "commit": "c460b738d83c83b4c76b10cd967e3641cbe35c7c"
6
6
  }
@@ -244,6 +244,13 @@ file is skipped (it contributes no rows) and a one-line warning naming the file
244
244
  and the error is written to stderr. One bad file never aborts the scan; the
245
245
  other files' rows are indexed normally.
246
246
 
247
+ **Timeout.** Each run is bounded by the default 30-second timeout. When a
248
+ command legitimately needs longer — it downloads a model on first run, or calls
249
+ a rate-limited API — raise the bound with the global
250
+ [`[dirsql].hook-timeout`](#command-execution) key (positive whole seconds). A
251
+ run exceeding the bound is killed and handled with the per-file error isolation
252
+ above (the file is skipped with a stderr warning).
253
+
247
254
  See [Command execution](#command-execution) for the full contract (argv
248
255
  splitting, injection safety, cwd, environment, timeout, and output framing).
249
256
 
@@ -279,7 +286,8 @@ key and the `{"sql": …}` contract returns.
279
286
  **On failure** — a non-zero exit, a timeout, or a spawn error — the request
280
287
  returns `500 Internal Server Error` with the command's stderr tail in the JSON
281
288
  `error` body. The command runs in the config file's directory and is bounded by
282
- a fixed **30-second** timeout.
289
+ a **30-second** timeout by default; a slow translator (e.g. LLM-backed) can
290
+ raise it with the global [`[dirsql].hook-timeout`](#command-execution) key.
283
291
 
284
292
  #### The hook owns SQL safety
285
293
 
@@ -361,7 +369,8 @@ returns.
361
369
  **On failure** — a non-zero exit, a timeout, or a spawn error — the request
362
370
  returns `500 Internal Server Error` with the command's stderr tail in the JSON
363
371
  `error` body. The command runs in the config file's directory and is bounded by
364
- a fixed **30-second** timeout.
372
+ a **30-second** timeout by default; raise it with the global
373
+ [`[dirsql].hook-timeout`](#command-execution) key.
365
374
 
366
375
  See [Command execution](#command-execution) for the full contract (argv
367
376
  splitting, injection safety, cwd, environment, timeout, and output framing).
@@ -409,9 +418,18 @@ Config keys that run an external command — today `on-file`, `pre-query`, and
409
418
  - **Output framing.** The command's result is the **last non-empty line of
410
419
  stdout**; any log/chatter lines above it are ignored. stderr is never data —
411
420
  it is captured only to enrich error messages.
412
- - **Timeout.** Each command run is bounded by a fixed **30-second** timeout (no
413
- per-table override yet); a command that exceeds it is killed and treated as a
414
- failure.
421
+ - **Timeout.** Each command run is bounded by a **30-second** timeout by
422
+ default; a command that exceeds it is killed and treated as a failure. Raise
423
+ (or tighten) it for **all** hooks — `on-file`, `pre-query`, and `post-query`
424
+ alike — with a single global `[dirsql].hook-timeout` key, in positive whole
425
+ seconds:
426
+
427
+ ```toml
428
+ [dirsql]
429
+ hook-timeout = 300
430
+ ```
431
+
432
+ Zero and negative values are rejected as a config error.
415
433
  - **Errors.** A non-zero exit, a timeout, a spawn failure, or output that does
416
434
  not parse as expected is a per-file failure: the file is skipped with a
417
435
  stderr warning and the scan continues.
@@ -228,9 +228,14 @@ fn load_pre_query(cli: &Cli) -> Option<PreQuery> {
228
228
  return None;
229
229
  }
230
230
  let resolved = config_path.canonicalize().ok()?;
231
- let command = dirsql::config::load_config(&resolved).ok()?.pre_query?;
231
+ let config = dirsql::config::load_config(&resolved).ok()?;
232
+ let command = config.pre_query?;
232
233
  let config_dir = resolved.parent()?.to_path_buf();
233
- Some(PreQuery::new(command, config_dir))
234
+ let mut pre_query = PreQuery::new(command, config_dir);
235
+ if let Some(timeout) = config.hook_timeout {
236
+ pre_query = pre_query.with_timeout(timeout);
237
+ }
238
+ Some(pre_query)
234
239
  }
235
240
 
236
241
  /// Extract the server-wide `post-query` hook from the config, if any.
@@ -245,9 +250,14 @@ fn load_post_query(cli: &Cli) -> Option<PostQuery> {
245
250
  return None;
246
251
  }
247
252
  let resolved = config_path.canonicalize().ok()?;
248
- let command = dirsql::config::load_config(&resolved).ok()?.post_query?;
253
+ let config = dirsql::config::load_config(&resolved).ok()?;
254
+ let command = config.post_query?;
249
255
  let config_dir = resolved.parent()?.to_path_buf();
250
- Some(PostQuery::new(command, config_dir))
256
+ let mut post_query = PostQuery::new(command, config_dir);
257
+ if let Some(timeout) = config.hook_timeout {
258
+ post_query = post_query.with_timeout(timeout);
259
+ }
260
+ Some(post_query)
251
261
  }
252
262
 
253
263
  /// Zero-config fallback. When no `.dirsql.toml` is found, dirsql indexes the
@@ -149,4 +149,21 @@ mod tests {
149
149
  let msg = format!("{err}").to_lowercase();
150
150
  assert!(msg.contains("claude"));
151
151
  }
152
+
153
+ /// `run` bails before spawning the agent when the output already exists and
154
+ /// `--force` was not passed (the temp dir itself is an existing path).
155
+ #[test]
156
+ fn run_bails_when_output_exists_without_force() {
157
+ let dir = tempfile::tempdir().unwrap();
158
+ let opts = InitOptions {
159
+ root: dir.path().to_path_buf(),
160
+ output: dir.path().to_path_buf(),
161
+ force: false,
162
+ };
163
+ let err = run(opts).unwrap_err();
164
+ assert!(
165
+ matches!(err, InitError::AlreadyExists { .. }),
166
+ "got: {err:?}"
167
+ );
168
+ }
152
169
  }
@@ -27,6 +27,7 @@ use tokio::task::JoinError;
27
27
  use tokio::task::JoinHandle;
28
28
 
29
29
  use crate::DirSQL;
30
+ use crate::command::DEFAULT_COMMAND_TIMEOUT;
30
31
 
31
32
  pub mod init;
32
33
  pub mod router;
@@ -51,16 +52,29 @@ pub struct PreQuery {
51
52
  pub command: String,
52
53
  /// The command's working directory — the config file's parent.
53
54
  pub config_dir: PathBuf,
55
+ /// Per-run timeout. Defaults to the shared 30-second
56
+ /// [`DEFAULT_COMMAND_TIMEOUT`]; override it via [`Self::with_timeout`]
57
+ /// (the CLI wires the global `[dirsql].hook-timeout` here, #351).
58
+ pub timeout: Duration,
54
59
  }
55
60
 
56
61
  impl PreQuery {
57
- /// Build a [`PreQuery`] from a command template and its working directory.
62
+ /// Build a [`PreQuery`] from a command template and its working directory,
63
+ /// with the default 30-second timeout.
58
64
  pub fn new(command: impl Into<String>, config_dir: impl Into<PathBuf>) -> Self {
59
65
  Self {
60
66
  command: command.into(),
61
67
  config_dir: config_dir.into(),
68
+ timeout: DEFAULT_COMMAND_TIMEOUT,
62
69
  }
63
70
  }
71
+
72
+ /// Override the per-run timeout (from the global `[dirsql].hook-timeout`).
73
+ /// A run exceeding it is killed and the request returns 500.
74
+ pub fn with_timeout(mut self, timeout: Duration) -> Self {
75
+ self.timeout = timeout;
76
+ self
77
+ }
64
78
  }
65
79
 
66
80
  /// A server-wide `post-query` command hook, carrying the command template plus
@@ -76,16 +90,29 @@ pub struct PostQuery {
76
90
  pub command: String,
77
91
  /// The command's working directory — the config file's parent.
78
92
  pub config_dir: PathBuf,
93
+ /// Per-run timeout. Defaults to the shared 30-second
94
+ /// [`DEFAULT_COMMAND_TIMEOUT`]; override it via [`Self::with_timeout`]
95
+ /// (the CLI wires the global `[dirsql].hook-timeout` here, #351).
96
+ pub timeout: Duration,
79
97
  }
80
98
 
81
99
  impl PostQuery {
82
- /// Build a [`PostQuery`] from a command template and its working directory.
100
+ /// Build a [`PostQuery`] from a command template and its working directory,
101
+ /// with the default 30-second timeout.
83
102
  pub fn new(command: impl Into<String>, config_dir: impl Into<PathBuf>) -> Self {
84
103
  Self {
85
104
  command: command.into(),
86
105
  config_dir: config_dir.into(),
106
+ timeout: DEFAULT_COMMAND_TIMEOUT,
87
107
  }
88
108
  }
109
+
110
+ /// Override the per-run timeout (from the global `[dirsql].hook-timeout`).
111
+ /// A run exceeding it is killed and the request returns 500.
112
+ pub fn with_timeout(mut self, timeout: Duration) -> Self {
113
+ self.timeout = timeout;
114
+ self
115
+ }
89
116
  }
90
117
 
91
118
  /// Configure how the server binds. Defaults to `localhost:7117` with a
@@ -258,10 +285,17 @@ mod tests {
258
285
  #[test]
259
286
  fn pre_query_constructor_carries_command_and_dir() {
260
287
  // `PreQuery::new` is pure data plumbing: the command template and the
261
- // working directory it will run in.
288
+ // working directory it will run in, with the shared default timeout.
262
289
  let pq = PreQuery::new("to_sql.py {args}", "/proj");
263
290
  assert_eq!(pq.command, "to_sql.py {args}");
264
291
  assert_eq!(pq.config_dir, PathBuf::from("/proj"));
292
+ assert_eq!(pq.timeout, Duration::from_secs(30));
293
+ }
294
+
295
+ #[test]
296
+ fn pre_query_with_timeout_overrides_the_default() {
297
+ let pq = PreQuery::new("cmd {args}", "/proj").with_timeout(Duration::from_secs(60));
298
+ assert_eq!(pq.timeout, Duration::from_secs(60));
265
299
  }
266
300
 
267
301
  #[test]
@@ -275,10 +309,17 @@ mod tests {
275
309
  #[test]
276
310
  fn post_query_constructor_carries_command_and_dir() {
277
311
  // `PostQuery::new` is pure data plumbing: the command template and the
278
- // working directory it will run in.
312
+ // working directory it will run in, with the shared default timeout.
279
313
  let pq = PostQuery::new("jq '{results: .}'", "/proj");
280
314
  assert_eq!(pq.command, "jq '{results: .}'");
281
315
  assert_eq!(pq.config_dir, PathBuf::from("/proj"));
316
+ assert_eq!(pq.timeout, Duration::from_secs(30));
317
+ }
318
+
319
+ #[test]
320
+ fn post_query_with_timeout_overrides_the_default() {
321
+ let pq = PostQuery::new("reshape {args}", "/proj").with_timeout(Duration::from_secs(60));
322
+ assert_eq!(pq.timeout, Duration::from_secs(60));
282
323
  }
283
324
 
284
325
  #[test]
@@ -21,15 +21,6 @@ use super::{AppState, PostQuery, PreQuery};
21
21
  use crate::command::{Placeholder, run_command};
22
22
  use crate::{DirSQL, DirSqlError};
23
23
 
24
- /// Fixed timeout for a server-wide `pre-query` command. There is no override
25
- /// key yet; this module constant is the documented current default (mirrors
26
- /// `on-file`'s `ON_FILE_TIMEOUT`).
27
- const PRE_QUERY_TIMEOUT: Duration = Duration::from_secs(30);
28
-
29
- /// Fixed timeout for a server-wide `post-query` command (mirrors
30
- /// `PRE_QUERY_TIMEOUT`).
31
- const POST_QUERY_TIMEOUT: Duration = Duration::from_secs(30);
32
-
33
24
  /// Cap on the serialized result payload passed as the `{args}` argv token.
34
25
  /// Beyond this, `{args}` is emptied and the operator is directed to stdin
35
26
  /// (which always carries the full payload) — comfortably under Linux's 128 KiB
@@ -155,15 +146,17 @@ fn parse_sql_body(body: &str) -> Result<String, Response> {
155
146
  async fn run_pre_query(pq: &PreQuery, raw_body: String) -> Result<String, Response> {
156
147
  let command = pq.command.clone();
157
148
  let config_dir = pq.config_dir.clone();
149
+ let timeout = pq.timeout;
158
150
  // `run_command` is blocking — it spawns a child and joins drain threads —
159
- // so run it off the async runtime. It enforces `PRE_QUERY_TIMEOUT`
160
- // internally, so no outer `tokio::time::timeout` is needed.
151
+ // so run it off the async runtime. It enforces the hook's timeout
152
+ // (the global `[dirsql].hook-timeout`, default 30s) internally, so no outer
153
+ // `tokio::time::timeout` is needed.
161
154
  let outcome = tokio::task::spawn_blocking(move || {
162
155
  run_command(
163
156
  &command,
164
157
  &[Placeholder::new("args", &raw_body)],
165
158
  &config_dir,
166
- PRE_QUERY_TIMEOUT,
159
+ timeout,
167
160
  None,
168
161
  )
169
162
  })
@@ -198,9 +191,11 @@ async fn run_post_query(
198
191
  .map_err(|err| error_response(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()))?;
199
192
  let command = pq.command.clone();
200
193
  let config_dir = pq.config_dir.clone();
194
+ let timeout = pq.timeout;
201
195
  // `run_command` is blocking — it spawns a child and joins drain threads —
202
- // so run it off the async runtime. It enforces `POST_QUERY_TIMEOUT`
203
- // internally, so no outer `tokio::time::timeout` is needed.
196
+ // so run it off the async runtime. It enforces the hook's timeout
197
+ // (the global `[dirsql].hook-timeout`, default 30s) internally, so no outer
198
+ // `tokio::time::timeout` is needed.
204
199
  let outcome = tokio::task::spawn_blocking(move || {
205
200
  let args_value = if payload.len() <= POST_QUERY_ARGS_MAX {
206
201
  payload.clone()
@@ -217,7 +212,7 @@ async fn run_post_query(
217
212
  &command,
218
213
  &[Placeholder::new("args", &args_value)],
219
214
  &config_dir,
220
- POST_QUERY_TIMEOUT,
215
+ timeout,
221
216
  Some(payload.as_bytes()),
222
217
  )
223
218
  })
@@ -114,4 +114,33 @@ mod tests {
114
114
 
115
115
  handle.shutdown().await.expect("graceful shutdown");
116
116
  }
117
+
118
+ // A `Ready` state drives the `serve` convenience wrapper plus
119
+ // `start_watch_task` (which attaches `DirSQL::watch` and pumps its stream
120
+ // into the broadcast channel). Built over an empty temp dir so the scan
121
+ // touches nothing; the port-0 bind and graceful shutdown are real.
122
+ #[tokio::test]
123
+ async fn serve_with_a_ready_db_attaches_the_watcher_then_shuts_down() {
124
+ let dir = tempfile::tempdir().unwrap();
125
+ let db = DirSQL::new(dir.path(), Vec::new()).unwrap();
126
+ let config = ServerConfig::bind("127.0.0.1".to_string(), 0);
127
+ let handle = serve(config, db).await.expect("bind on an ephemeral port");
128
+ assert_ne!(handle.local_addr().port(), 0);
129
+ handle.shutdown().await.expect("graceful shutdown");
130
+ }
131
+
132
+ // Binding to a non-local TEST-NET address (RFC 5737) fails with
133
+ // "cannot assign requested address", surfacing `ServerError::Bind` rather
134
+ // than panicking — deterministic and DNS-free.
135
+ #[tokio::test]
136
+ async fn serve_with_state_surfaces_a_bind_error_for_a_nonlocal_address() {
137
+ let config = ServerConfig::bind("192.0.2.1".to_string(), 9);
138
+ // `serve_with_state` returns `Result<ServerHandle, _>`; ServerHandle is
139
+ // not Debug, so match rather than `unwrap_err`.
140
+ let err = match serve_with_state(config, AppState::Unavailable("x".to_string())).await {
141
+ Ok(_) => panic!("expected a bind error"),
142
+ Err(e) => e,
143
+ };
144
+ assert!(matches!(err, ServerError::Bind { .. }), "got: {err:?}");
145
+ }
117
146
  }
@@ -36,6 +36,11 @@ use std::time::Duration;
36
36
 
37
37
  use wait_timeout::ChildExt;
38
38
 
39
+ /// The default timeout for every command-backed event (`on-file`,
40
+ /// `pre-query`, `post-query`) when the config declares no override
41
+ /// (`[dirsql].hook-timeout`, positive whole seconds).
42
+ pub const DEFAULT_COMMAND_TIMEOUT: Duration = Duration::from_secs(30);
43
+
39
44
  /// A named placeholder substituted into a command's argv.
40
45
  ///
41
46
  /// `name` is the bare identifier (no braces): a `name` of `path` matches the
@@ -484,4 +489,103 @@ mod tests {
484
489
  assert!(!Placeholder::new("a", "b").append_if_absent);
485
490
  assert!(Placeholder::append("a", "b").append_if_absent);
486
491
  }
492
+
493
+ // ----- run_command end-to-end (spawns trivial POSIX commands) ----------
494
+ //
495
+ // `run_command` can only be exercised by actually spawning a child, so
496
+ // these drive `sh`/`echo`/`cat` — universally present on the Linux CI
497
+ // runners. The test code itself statically references only `super::` items,
498
+ // `Duration`, and `std::path::Path`, so the `unit lint` isolation rule is
499
+ // satisfied; the effectful process/thread work lives in production code.
500
+
501
+ fn cwd() -> std::path::PathBuf {
502
+ std::path::PathBuf::from(".")
503
+ }
504
+
505
+ #[test]
506
+ fn run_command_returns_last_nonempty_stdout_line() {
507
+ let out = run_command(
508
+ "sh -c 'echo chatter; echo PAYLOAD'",
509
+ &[],
510
+ &cwd(),
511
+ Duration::from_secs(30),
512
+ None,
513
+ )
514
+ .unwrap();
515
+ assert_eq!(out.payload, "PAYLOAD");
516
+ }
517
+
518
+ #[test]
519
+ fn run_command_writes_stdin_payload_to_the_child() {
520
+ // `cat` echoes its stdin, so the payload round-trips through the
521
+ // stdin-writer thread and the stdout drain.
522
+ let out = run_command(
523
+ "cat",
524
+ &[],
525
+ &cwd(),
526
+ Duration::from_secs(30),
527
+ Some(b"hello-stdin"),
528
+ )
529
+ .unwrap();
530
+ assert_eq!(out.payload, "hello-stdin");
531
+ }
532
+
533
+ #[test]
534
+ fn run_command_reports_nonzero_exit_with_stderr_tail() {
535
+ let err = run_command(
536
+ "sh -c 'echo oops >&2; exit 3'",
537
+ &[],
538
+ &cwd(),
539
+ Duration::from_secs(30),
540
+ None,
541
+ )
542
+ .unwrap_err();
543
+ match err {
544
+ CommandError::NonZeroExit {
545
+ code, stderr_tail, ..
546
+ } => {
547
+ assert_eq!(code, "3");
548
+ assert!(stderr_tail.contains("oops"), "got: {stderr_tail}");
549
+ }
550
+ other => panic!("expected NonZeroExit, got {other:?}"),
551
+ }
552
+ }
553
+
554
+ #[test]
555
+ fn run_command_reports_empty_output_when_stdout_is_blank() {
556
+ let err = run_command("true", &[], &cwd(), Duration::from_secs(30), None).unwrap_err();
557
+ assert!(
558
+ matches!(err, CommandError::EmptyOutput { .. }),
559
+ "got: {err:?}"
560
+ );
561
+ }
562
+
563
+ #[test]
564
+ fn run_command_reports_spawn_failure_for_a_missing_program() {
565
+ let err = run_command(
566
+ "definitely-not-a-real-binary-xyzzy",
567
+ &[],
568
+ &cwd(),
569
+ Duration::from_secs(30),
570
+ None,
571
+ )
572
+ .unwrap_err();
573
+ assert!(matches!(err, CommandError::Spawn { .. }), "got: {err:?}");
574
+ }
575
+
576
+ #[test]
577
+ fn run_command_kills_and_reports_timeout_even_with_stdin() {
578
+ // A short timeout against a long sleep drives the timeout branch,
579
+ // including joining the stdin-writer thread. `sh` ignoring stdin closes
580
+ // the pipe early, which the writer treats as a clean EOF.
581
+ let err = run_command(
582
+ "sh -c 'sleep 30'",
583
+ &[],
584
+ &cwd(),
585
+ Duration::from_millis(50),
586
+ Some(b"x"),
587
+ )
588
+ .unwrap_err();
589
+ assert!(matches!(err, CommandError::Timeout { .. }), "got: {err:?}");
590
+ }
487
591
  }