dirsql 0.3.43__tar.gz → 0.3.44__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 (119) hide show
  1. {dirsql-0.3.43 → dirsql-0.3.44}/Cargo.lock +3 -1
  2. {dirsql-0.3.43 → dirsql-0.3.44}/PKG-INFO +1 -1
  3. {dirsql-0.3.43 → dirsql-0.3.44}/packages/python/Cargo.toml +1 -1
  4. {dirsql-0.3.43 → dirsql-0.3.44}/packages/rust/Cargo.toml +6 -0
  5. dirsql-0.3.44/packages/rust/src/command.rs +487 -0
  6. {dirsql-0.3.43 → dirsql-0.3.44}/packages/rust/src/lib.rs +2 -0
  7. {dirsql-0.3.43 → dirsql-0.3.44}/Cargo.toml +0 -0
  8. {dirsql-0.3.43 → dirsql-0.3.44}/README.md +0 -0
  9. {dirsql-0.3.43 → dirsql-0.3.44}/dirsql/__init__.py +0 -0
  10. {dirsql-0.3.43 → dirsql-0.3.44}/dirsql/_async.py +0 -0
  11. {dirsql-0.3.43 → dirsql-0.3.44}/dirsql/_dirsql.pyi +0 -0
  12. {dirsql-0.3.43 → dirsql-0.3.44}/dirsql/cli/__init__.py +0 -0
  13. {dirsql-0.3.43 → dirsql-0.3.44}/dirsql/cli/binary_path.py +0 -0
  14. {dirsql-0.3.43 → dirsql-0.3.44}/dirsql/cli/interpret/__init__.py +0 -0
  15. {dirsql-0.3.43 → dirsql-0.3.44}/dirsql/cli/is_windows.py +0 -0
  16. {dirsql-0.3.43 → dirsql-0.3.44}/dirsql/cli/main.py +0 -0
  17. {dirsql-0.3.43 → dirsql-0.3.44}/dirsql/py.typed +0 -0
  18. {dirsql-0.3.43 → dirsql-0.3.44}/docs/.claude/CLAUDE.md +0 -0
  19. {dirsql-0.3.43 → dirsql-0.3.44}/docs/.vitepress/config.ts +0 -0
  20. {dirsql-0.3.43 → dirsql-0.3.44}/docs/.vitepress/theme/index.ts +0 -0
  21. {dirsql-0.3.43 → dirsql-0.3.44}/docs/.vitepress/theme/lang.ts +0 -0
  22. {dirsql-0.3.43 → dirsql-0.3.44}/docs/AGENTS.md +0 -0
  23. {dirsql-0.3.43 → dirsql-0.3.44}/docs/api/index.md +0 -0
  24. {dirsql-0.3.43 → dirsql-0.3.44}/docs/cli/config.md +0 -0
  25. {dirsql-0.3.43 → dirsql-0.3.44}/docs/cli/http-api.md +0 -0
  26. {dirsql-0.3.43 → dirsql-0.3.44}/docs/cli/index.md +0 -0
  27. {dirsql-0.3.43 → dirsql-0.3.44}/docs/cli/init.md +0 -0
  28. {dirsql-0.3.43 → dirsql-0.3.44}/docs/cli/server.md +0 -0
  29. {dirsql-0.3.43 → dirsql-0.3.44}/docs/getting-started.md +0 -0
  30. {dirsql-0.3.43 → dirsql-0.3.44}/docs/guide/async.md +0 -0
  31. {dirsql-0.3.43 → dirsql-0.3.44}/docs/guide/crdt.md +0 -0
  32. {dirsql-0.3.43 → dirsql-0.3.44}/docs/guide/persistence.md +0 -0
  33. {dirsql-0.3.43 → dirsql-0.3.44}/docs/guide/querying.md +0 -0
  34. {dirsql-0.3.43 → dirsql-0.3.44}/docs/guide/tables.md +0 -0
  35. {dirsql-0.3.43 → dirsql-0.3.44}/docs/guide/watching.md +0 -0
  36. {dirsql-0.3.43 → dirsql-0.3.44}/docs/index.md +0 -0
  37. {dirsql-0.3.43 → dirsql-0.3.44}/docs/migrations.md +0 -0
  38. {dirsql-0.3.43 → dirsql-0.3.44}/docs/package.json +0 -0
  39. {dirsql-0.3.43 → dirsql-0.3.44}/docs/playwright.config.ts +0 -0
  40. {dirsql-0.3.43 → dirsql-0.3.44}/docs/pnpm-lock.yaml +0 -0
  41. {dirsql-0.3.43 → dirsql-0.3.44}/docs/pnpm-workspace.yaml +0 -0
  42. {dirsql-0.3.43 → dirsql-0.3.44}/docs/tests/integration/home.spec.ts +0 -0
  43. {dirsql-0.3.43 → dirsql-0.3.44}/docs/tests/integration/language-flag.spec.ts +0 -0
  44. {dirsql-0.3.43 → dirsql-0.3.44}/docs/tests/integration/sidebar.spec.ts +0 -0
  45. {dirsql-0.3.43 → dirsql-0.3.44}/docs/tests/unit/config.test.ts +0 -0
  46. {dirsql-0.3.43 → dirsql-0.3.44}/docs/tests/unit/lang.test.ts +0 -0
  47. {dirsql-0.3.43 → dirsql-0.3.44}/docs/vitest.config.ts +0 -0
  48. {dirsql-0.3.43 → dirsql-0.3.44}/packages/python/README.md +0 -0
  49. {dirsql-0.3.43 → dirsql-0.3.44}/packages/python/conftest.py +0 -0
  50. {dirsql-0.3.43 → dirsql-0.3.44}/packages/python/docs/.claude/CLAUDE.md +0 -0
  51. {dirsql-0.3.43 → dirsql-0.3.44}/packages/python/docs/.vitepress/config.ts +0 -0
  52. {dirsql-0.3.43 → dirsql-0.3.44}/packages/python/docs/.vitepress/theme/index.ts +0 -0
  53. {dirsql-0.3.43 → dirsql-0.3.44}/packages/python/docs/.vitepress/theme/lang.ts +0 -0
  54. {dirsql-0.3.43 → dirsql-0.3.44}/packages/python/docs/AGENTS.md +0 -0
  55. {dirsql-0.3.43 → dirsql-0.3.44}/packages/python/docs/api/index.md +0 -0
  56. {dirsql-0.3.43 → dirsql-0.3.44}/packages/python/docs/cli/config.md +0 -0
  57. {dirsql-0.3.43 → dirsql-0.3.44}/packages/python/docs/cli/http-api.md +0 -0
  58. {dirsql-0.3.43 → dirsql-0.3.44}/packages/python/docs/cli/index.md +0 -0
  59. {dirsql-0.3.43 → dirsql-0.3.44}/packages/python/docs/cli/init.md +0 -0
  60. {dirsql-0.3.43 → dirsql-0.3.44}/packages/python/docs/cli/server.md +0 -0
  61. {dirsql-0.3.43 → dirsql-0.3.44}/packages/python/docs/getting-started.md +0 -0
  62. {dirsql-0.3.43 → dirsql-0.3.44}/packages/python/docs/guide/async.md +0 -0
  63. {dirsql-0.3.43 → dirsql-0.3.44}/packages/python/docs/guide/crdt.md +0 -0
  64. {dirsql-0.3.43 → dirsql-0.3.44}/packages/python/docs/guide/persistence.md +0 -0
  65. {dirsql-0.3.43 → dirsql-0.3.44}/packages/python/docs/guide/querying.md +0 -0
  66. {dirsql-0.3.43 → dirsql-0.3.44}/packages/python/docs/guide/tables.md +0 -0
  67. {dirsql-0.3.43 → dirsql-0.3.44}/packages/python/docs/guide/watching.md +0 -0
  68. {dirsql-0.3.43 → dirsql-0.3.44}/packages/python/docs/index.md +0 -0
  69. {dirsql-0.3.43 → dirsql-0.3.44}/packages/python/docs/migrations.md +0 -0
  70. {dirsql-0.3.43 → dirsql-0.3.44}/packages/python/docs/package.json +0 -0
  71. {dirsql-0.3.43 → dirsql-0.3.44}/packages/python/docs/playwright.config.ts +0 -0
  72. {dirsql-0.3.43 → dirsql-0.3.44}/packages/python/docs/pnpm-lock.yaml +0 -0
  73. {dirsql-0.3.43 → dirsql-0.3.44}/packages/python/docs/pnpm-workspace.yaml +0 -0
  74. {dirsql-0.3.43 → dirsql-0.3.44}/packages/python/docs/tests/integration/home.spec.ts +0 -0
  75. {dirsql-0.3.43 → dirsql-0.3.44}/packages/python/docs/tests/integration/language-flag.spec.ts +0 -0
  76. {dirsql-0.3.43 → dirsql-0.3.44}/packages/python/docs/tests/integration/sidebar.spec.ts +0 -0
  77. {dirsql-0.3.43 → dirsql-0.3.44}/packages/python/docs/tests/unit/config.test.ts +0 -0
  78. {dirsql-0.3.43 → dirsql-0.3.44}/packages/python/docs/tests/unit/lang.test.ts +0 -0
  79. {dirsql-0.3.43 → dirsql-0.3.44}/packages/python/docs/vitest.config.ts +0 -0
  80. {dirsql-0.3.43 → dirsql-0.3.44}/packages/python/e2e-attestation.json +0 -0
  81. {dirsql-0.3.43 → dirsql-0.3.44}/packages/python/src/lib.rs +0 -0
  82. {dirsql-0.3.43 → dirsql-0.3.44}/packages/python/tests/__init__.py +0 -0
  83. {dirsql-0.3.43 → dirsql-0.3.44}/packages/python/tests/conftest.py +0 -0
  84. {dirsql-0.3.43 → dirsql-0.3.44}/packages/python/tests/e2e/__init__.py +0 -0
  85. {dirsql-0.3.43 → dirsql-0.3.44}/packages/python/tests/integration/__init__.py +0 -0
  86. {dirsql-0.3.43 → dirsql-0.3.44}/packages/rust/README.md +0 -0
  87. {dirsql-0.3.43 → dirsql-0.3.44}/packages/rust/benches/db_bench.rs +0 -0
  88. {dirsql-0.3.43 → dirsql-0.3.44}/packages/rust/benches/differ_bench.rs +0 -0
  89. {dirsql-0.3.43 → dirsql-0.3.44}/packages/rust/benches/matcher_bench.rs +0 -0
  90. {dirsql-0.3.43 → dirsql-0.3.44}/packages/rust/benches/scanner_bench.rs +0 -0
  91. {dirsql-0.3.43 → dirsql-0.3.44}/packages/rust/docs/api/index.md +0 -0
  92. {dirsql-0.3.43 → dirsql-0.3.44}/packages/rust/docs/cli/config.md +0 -0
  93. {dirsql-0.3.43 → dirsql-0.3.44}/packages/rust/docs/cli/http-api.md +0 -0
  94. {dirsql-0.3.43 → dirsql-0.3.44}/packages/rust/docs/cli/index.md +0 -0
  95. {dirsql-0.3.43 → dirsql-0.3.44}/packages/rust/docs/cli/init.md +0 -0
  96. {dirsql-0.3.43 → dirsql-0.3.44}/packages/rust/docs/cli/server.md +0 -0
  97. {dirsql-0.3.43 → dirsql-0.3.44}/packages/rust/docs/getting-started.md +0 -0
  98. {dirsql-0.3.43 → dirsql-0.3.44}/packages/rust/docs/guide/async.md +0 -0
  99. {dirsql-0.3.43 → dirsql-0.3.44}/packages/rust/docs/guide/crdt.md +0 -0
  100. {dirsql-0.3.43 → dirsql-0.3.44}/packages/rust/docs/guide/persistence.md +0 -0
  101. {dirsql-0.3.43 → dirsql-0.3.44}/packages/rust/docs/guide/querying.md +0 -0
  102. {dirsql-0.3.43 → dirsql-0.3.44}/packages/rust/docs/guide/tables.md +0 -0
  103. {dirsql-0.3.43 → dirsql-0.3.44}/packages/rust/docs/guide/watching.md +0 -0
  104. {dirsql-0.3.43 → dirsql-0.3.44}/packages/rust/docs/index.md +0 -0
  105. {dirsql-0.3.43 → dirsql-0.3.44}/packages/rust/docs/migrations.md +0 -0
  106. {dirsql-0.3.43 → dirsql-0.3.44}/packages/rust/src/bin/dirsql.rs +0 -0
  107. {dirsql-0.3.43 → dirsql-0.3.44}/packages/rust/src/cli/init.rs +0 -0
  108. {dirsql-0.3.43 → dirsql-0.3.44}/packages/rust/src/cli/mod.rs +0 -0
  109. {dirsql-0.3.43 → dirsql-0.3.44}/packages/rust/src/cli/router.rs +0 -0
  110. {dirsql-0.3.43 → dirsql-0.3.44}/packages/rust/src/cli/serialize.rs +0 -0
  111. {dirsql-0.3.43 → dirsql-0.3.44}/packages/rust/src/cli/server.rs +0 -0
  112. {dirsql-0.3.43 → dirsql-0.3.44}/packages/rust/src/config.rs +0 -0
  113. {dirsql-0.3.43 → dirsql-0.3.44}/packages/rust/src/db.rs +0 -0
  114. {dirsql-0.3.43 → dirsql-0.3.44}/packages/rust/src/differ.rs +0 -0
  115. {dirsql-0.3.43 → dirsql-0.3.44}/packages/rust/src/matcher.rs +0 -0
  116. {dirsql-0.3.43 → dirsql-0.3.44}/packages/rust/src/persist.rs +0 -0
  117. {dirsql-0.3.43 → dirsql-0.3.44}/packages/rust/src/scanner.rs +0 -0
  118. {dirsql-0.3.43 → dirsql-0.3.44}/packages/rust/src/watcher.rs +0 -0
  119. {dirsql-0.3.43 → dirsql-0.3.44}/pyproject.toml +0 -0
@@ -478,11 +478,13 @@ dependencies = [
478
478
  "rusqlite",
479
479
  "serde",
480
480
  "serde_json",
481
+ "shlex",
481
482
  "tempfile",
482
483
  "thiserror",
483
484
  "tokio",
484
485
  "tokio-stream",
485
486
  "toml",
487
+ "wait-timeout",
486
488
  "walkdir",
487
489
  ]
488
490
 
@@ -499,7 +501,7 @@ dependencies = [
499
501
 
500
502
  [[package]]
501
503
  name = "dirsql-py-ext"
502
- version = "0.3.43"
504
+ version = "0.3.44"
503
505
  dependencies = [
504
506
  "dirsql",
505
507
  "pyo3",
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dirsql
3
- Version: 0.3.43
3
+ Version: 0.3.44
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'
@@ -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.43"
7
+ version = "0.3.44"
8
8
  edition.workspace = true
9
9
  publish = false
10
10
  readme = "README.md"
@@ -60,10 +60,16 @@ regex.workspace = true
60
60
  rusqlite = { version = "0.34", features = ["bundled", "load_extension"] }
61
61
  serde.workspace = true
62
62
  serde_json.workspace = true
63
+ # Split a command *template* into argv with shell-like quoting (so
64
+ # `sh -c '...'` keeps its script as one arg) WITHOUT invoking a shell.
65
+ shlex = "1"
63
66
  thiserror.workspace = true
64
67
  tokio.workspace = true
65
68
  tokio-stream = { version = "0.1", features = ["sync"], optional = true }
66
69
  toml = "0.8"
70
+ # Enforce a per-command timeout: wait for the child up to a deadline,
71
+ # then kill it. No async runtime required in the core.
72
+ wait-timeout = "0.2"
67
73
  walkdir = "2"
68
74
 
69
75
  [[bin]]
@@ -0,0 +1,487 @@
1
+ //! Reusable shell-command runner — the foundation for the command-backed
2
+ //! events (`on-file`, `pre-query`, `post-query`; Epic B / #322). This module
3
+ //! owns command execution; the events are thin wiring on top of it (#326).
4
+ //!
5
+ //! ## Contract
6
+ //!
7
+ //! - **argv, not a shell.** The command *template* is split into argv with
8
+ //! shell-like quoting (via [`shlex`]) so `sh -c '…'` keeps its script as a
9
+ //! single argument — but no shell is ever invoked: there is no globbing,
10
+ //! piping, or variable expansion. To get a real shell, ask for one
11
+ //! explicitly (`sh -c '…'`).
12
+ //! - **Placeholders.** `{path}`, `{args}`, `{abspath}`, `{root}` (and any
13
+ //! others a caller supplies) are substituted into whole argv tokens, every
14
+ //! occurrence. Substitution is single-pass and left-to-right per token, so a
15
+ //! value that itself contains `{…}` is never re-scanned — a substituted value
16
+ //! is always exactly one argv element, keeping values with spaces (and
17
+ //! untrusted input) injection-safe. An unknown `{…}` is left literal.
18
+ //! - **append-if-absent.** A [`Placeholder`] marked [`Placeholder::append`] is
19
+ //! appended as a final argv element when its `{name}` does not appear in the
20
+ //! template (the `on-file` ergonomic: `cmd {path}` and `cmd` behave the same).
21
+ //! - **cwd / env / timeout.** The child runs in `cwd` (the config file's
22
+ //! directory), inherits dirsql's environment (so `uvx --with …` / `npx …`
23
+ //! dependency resolution works), and is killed if it exceeds `timeout`.
24
+ //! - **stdin.** An optional payload is written to the child's stdin (used by
25
+ //! events whose payload may exceed the OS argv limit).
26
+ //! - **Framing.** The output payload is the **last non-empty line of stdout**;
27
+ //! any chatter/log lines above it are ignored. stderr is never data — it is
28
+ //! captured only to enrich errors.
29
+ //! - **Errors.** A non-zero exit or a timeout is a failure carrying the tail of
30
+ //! stderr.
31
+
32
+ use std::io::{Read, Write};
33
+ use std::path::Path;
34
+ use std::process::{Command, Stdio};
35
+ use std::time::Duration;
36
+
37
+ use wait_timeout::ChildExt;
38
+
39
+ /// A named placeholder substituted into a command's argv.
40
+ ///
41
+ /// `name` is the bare identifier (no braces): a `name` of `path` matches the
42
+ /// template token `{path}`.
43
+ #[derive(Debug, Clone)]
44
+ pub struct Placeholder {
45
+ pub name: String,
46
+ pub value: String,
47
+ /// When `true` and the template contains no `{name}`, `value` is appended
48
+ /// as a final argv element instead of being dropped.
49
+ pub append_if_absent: bool,
50
+ }
51
+
52
+ impl Placeholder {
53
+ /// A substitute-only placeholder: replaces `{name}` where it appears, and
54
+ /// is a no-op when the template omits it.
55
+ pub fn new(name: impl Into<String>, value: impl Into<String>) -> Self {
56
+ Self {
57
+ name: name.into(),
58
+ value: value.into(),
59
+ append_if_absent: false,
60
+ }
61
+ }
62
+
63
+ /// A placeholder that is *appended* to argv when the template omits its
64
+ /// `{name}` (the `on-file` `{path}` ergonomic).
65
+ pub fn append(name: impl Into<String>, value: impl Into<String>) -> Self {
66
+ Self {
67
+ name: name.into(),
68
+ value: value.into(),
69
+ append_if_absent: true,
70
+ }
71
+ }
72
+ }
73
+
74
+ /// A successful command run.
75
+ #[derive(Debug, Clone, PartialEq, Eq)]
76
+ pub struct CommandOutput {
77
+ /// The last non-empty line of stdout (trimmed).
78
+ pub payload: String,
79
+ }
80
+
81
+ /// Everything that can go wrong running a command.
82
+ #[derive(Debug, thiserror::Error)]
83
+ pub enum CommandError {
84
+ /// The template was empty (or only whitespace), or its quoting was
85
+ /// unbalanced, so it produced no argv.
86
+ #[error("invalid command template: {0:?}")]
87
+ InvalidCommand(String),
88
+
89
+ /// The child could not be spawned (e.g. the program was not found).
90
+ #[error("failed to spawn `{command}`: {source}")]
91
+ Spawn {
92
+ command: String,
93
+ #[source]
94
+ source: std::io::Error,
95
+ },
96
+
97
+ /// The child exited with a non-zero status. `code` is the exit code, or
98
+ /// `"signal"` when the child was terminated by a signal.
99
+ #[error("command `{command}` failed (exit {code}): {stderr_tail}")]
100
+ NonZeroExit {
101
+ command: String,
102
+ code: String,
103
+ stderr_tail: String,
104
+ },
105
+
106
+ /// The child ran longer than the configured timeout and was killed.
107
+ #[error("command `{command}` timed out after {timeout:?}: {stderr_tail}")]
108
+ Timeout {
109
+ command: String,
110
+ timeout: Duration,
111
+ stderr_tail: String,
112
+ },
113
+
114
+ /// The child exited cleanly but wrote no non-empty line to stdout.
115
+ #[error("command `{command}` produced no output on stdout")]
116
+ EmptyOutput { command: String },
117
+
118
+ /// An I/O error while waiting on the child.
119
+ #[error("command `{command}` I/O error: {source}")]
120
+ Io {
121
+ command: String,
122
+ #[source]
123
+ source: std::io::Error,
124
+ },
125
+ }
126
+
127
+ /// Run `command` to completion and return its payload.
128
+ ///
129
+ /// See the [module docs](self) for the full contract. `cwd` is the child's
130
+ /// working directory (the config file's directory), `timeout` bounds the run,
131
+ /// and `stdin_payload`, when `Some`, is written to the child's stdin.
132
+ pub fn run_command(
133
+ command: &str,
134
+ placeholders: &[Placeholder],
135
+ cwd: &Path,
136
+ timeout: Duration,
137
+ stdin_payload: Option<&[u8]>,
138
+ ) -> Result<CommandOutput, CommandError> {
139
+ let argv = build_argv(command, placeholders)?;
140
+ // `build_argv` guarantees a non-empty argv.
141
+ let mut cmd = Command::new(&argv[0]);
142
+ cmd.args(&argv[1..])
143
+ .current_dir(cwd)
144
+ .stdout(Stdio::piped())
145
+ .stderr(Stdio::piped())
146
+ .stdin(if stdin_payload.is_some() {
147
+ Stdio::piped()
148
+ } else {
149
+ Stdio::null()
150
+ });
151
+
152
+ let mut child = cmd.spawn().map_err(|source| CommandError::Spawn {
153
+ command: command.to_string(),
154
+ source,
155
+ })?;
156
+
157
+ // Feed stdin and drain stdout/stderr on their own threads. Draining the
158
+ // output pipes concurrently is required: a child that writes more than a
159
+ // pipe buffer would otherwise block on write while we block on `wait`,
160
+ // deadlocking. Writing stdin on a thread lets a large payload flow while
161
+ // the child streams output.
162
+ let stdin_thread = stdin_payload.map(|payload| {
163
+ let mut stdin = child.stdin.take().expect("stdin piped");
164
+ let payload = payload.to_vec();
165
+ std::thread::spawn(move || {
166
+ // A child that ignores stdin closes the pipe early; a broken-pipe
167
+ // write is expected, not an error. Dropping `stdin` sends EOF.
168
+ let _ = stdin.write_all(&payload);
169
+ })
170
+ });
171
+
172
+ let mut stdout_pipe = child.stdout.take().expect("stdout piped");
173
+ let mut stderr_pipe = child.stderr.take().expect("stderr piped");
174
+ let stdout_thread = std::thread::spawn(move || {
175
+ let mut buf = Vec::new();
176
+ let _ = stdout_pipe.read_to_end(&mut buf);
177
+ buf
178
+ });
179
+ let stderr_thread = std::thread::spawn(move || {
180
+ let mut buf = Vec::new();
181
+ let _ = stderr_pipe.read_to_end(&mut buf);
182
+ buf
183
+ });
184
+
185
+ let status = match child
186
+ .wait_timeout(timeout)
187
+ .map_err(|source| CommandError::Io {
188
+ command: command.to_string(),
189
+ source,
190
+ })? {
191
+ Some(status) => status,
192
+ None => {
193
+ // Timed out: kill, reap, then collect whatever the pipes hold.
194
+ let _ = child.kill();
195
+ let _ = child.wait();
196
+ if let Some(t) = stdin_thread {
197
+ let _ = t.join();
198
+ }
199
+ let _ = stdout_thread.join();
200
+ let stderr = stderr_thread.join().unwrap_or_default();
201
+ return Err(CommandError::Timeout {
202
+ command: command.to_string(),
203
+ timeout,
204
+ stderr_tail: stderr_tail(&stderr),
205
+ });
206
+ }
207
+ };
208
+
209
+ if let Some(t) = stdin_thread {
210
+ let _ = t.join();
211
+ }
212
+ let stdout = stdout_thread.join().unwrap_or_default();
213
+ let stderr = stderr_thread.join().unwrap_or_default();
214
+
215
+ if !status.success() {
216
+ let code = status
217
+ .code()
218
+ .map(|c| c.to_string())
219
+ .unwrap_or_else(|| "signal".to_string());
220
+ return Err(CommandError::NonZeroExit {
221
+ command: command.to_string(),
222
+ code,
223
+ stderr_tail: stderr_tail(&stderr),
224
+ });
225
+ }
226
+
227
+ let stdout = String::from_utf8_lossy(&stdout);
228
+ match extract_payload(&stdout) {
229
+ Some(payload) => Ok(CommandOutput { payload }),
230
+ None => Err(CommandError::EmptyOutput {
231
+ command: command.to_string(),
232
+ }),
233
+ }
234
+ }
235
+
236
+ /// Split a command template into argv (shell-like quoting, no shell) and
237
+ /// substitute placeholders into whole tokens.
238
+ fn build_argv(command: &str, placeholders: &[Placeholder]) -> Result<Vec<String>, CommandError> {
239
+ let tokens =
240
+ shlex::split(command).ok_or_else(|| CommandError::InvalidCommand(command.to_string()))?;
241
+ if tokens.is_empty() {
242
+ return Err(CommandError::InvalidCommand(command.to_string()));
243
+ }
244
+
245
+ let mut used = vec![false; placeholders.len()];
246
+ let mut argv: Vec<String> = tokens
247
+ .iter()
248
+ .map(|token| substitute(token, placeholders, &mut used))
249
+ .collect();
250
+
251
+ for (i, ph) in placeholders.iter().enumerate() {
252
+ if ph.append_if_absent && !used[i] {
253
+ argv.push(ph.value.clone());
254
+ }
255
+ }
256
+
257
+ Ok(argv)
258
+ }
259
+
260
+ /// Replace every `{name}` in `token` with its placeholder value in a single
261
+ /// left-to-right pass. Injected values are never re-scanned, so an untrusted
262
+ /// value containing `{…}` is inert. Unknown `{…}` sequences are left literal.
263
+ /// Marks `used[i]` for each placeholder that was substituted at least once.
264
+ fn substitute(token: &str, placeholders: &[Placeholder], used: &mut [bool]) -> String {
265
+ let mut out = String::with_capacity(token.len());
266
+ let mut i = 0;
267
+ while i < token.len() {
268
+ if let Some((idx, consumed)) = match_placeholder(token, i, placeholders) {
269
+ out.push_str(&placeholders[idx].value);
270
+ used[idx] = true;
271
+ i += consumed;
272
+ continue;
273
+ }
274
+ // Not a recognized placeholder: copy one whole UTF-8 char.
275
+ let ch = token[i..].chars().next().expect("valid utf-8");
276
+ out.push(ch);
277
+ i += ch.len_utf8();
278
+ }
279
+ out
280
+ }
281
+
282
+ /// If a recognized `{name}` starts at byte `i` of `token`, return the matching
283
+ /// placeholder index and the number of bytes consumed; otherwise `None`.
284
+ fn match_placeholder(
285
+ token: &str,
286
+ i: usize,
287
+ placeholders: &[Placeholder],
288
+ ) -> Option<(usize, usize)> {
289
+ // Braces are ASCII, so byte indexing is safe at these positions.
290
+ if token.as_bytes()[i] != b'{' {
291
+ return None;
292
+ }
293
+ let rel = token[i + 1..].find('}')?;
294
+ let name = &token[i + 1..i + 1 + rel];
295
+ let idx = placeholders.iter().position(|p| p.name == name)?;
296
+ Some((idx, 1 + rel + 1))
297
+ }
298
+
299
+ /// The payload framing: the last line of `stdout` that is non-empty after
300
+ /// trimming, returned trimmed. `None` when every line is blank.
301
+ fn extract_payload(stdout: &str) -> Option<String> {
302
+ stdout
303
+ .lines()
304
+ .rev()
305
+ .map(str::trim)
306
+ .find(|line| !line.is_empty())
307
+ .map(str::to_string)
308
+ }
309
+
310
+ /// The tail of stderr for error messages: lossy UTF-8, trimmed, capped to the
311
+ /// last `MAX` characters (prefixed with `…` when truncated).
312
+ fn stderr_tail(stderr: &[u8]) -> String {
313
+ const MAX: usize = 2000;
314
+ let text = String::from_utf8_lossy(stderr);
315
+ let trimmed = text.trim();
316
+ let count = trimmed.chars().count();
317
+ if count <= MAX {
318
+ trimmed.to_string()
319
+ } else {
320
+ let tail: String = trimmed.chars().skip(count - MAX).collect();
321
+ format!("…{tail}")
322
+ }
323
+ }
324
+
325
+ #[cfg(test)]
326
+ mod tests {
327
+ use super::*;
328
+
329
+ fn argv(command: &str, placeholders: &[Placeholder]) -> Vec<String> {
330
+ build_argv(command, placeholders).expect("valid command")
331
+ }
332
+
333
+ #[test]
334
+ fn splits_on_whitespace_without_placeholders() {
335
+ assert_eq!(argv("echo hello world", &[]), ["echo", "hello", "world"]);
336
+ }
337
+
338
+ #[test]
339
+ fn respects_shell_quoting_so_sh_c_keeps_its_script_as_one_arg() {
340
+ // Quote-aware split (no shell invoked): the quoted script is one token.
341
+ assert_eq!(
342
+ argv("sh -c 'echo one two'", &[]),
343
+ ["sh", "-c", "echo one two"]
344
+ );
345
+ }
346
+
347
+ #[test]
348
+ fn empty_or_whitespace_template_is_invalid() {
349
+ assert!(matches!(
350
+ build_argv("", &[]),
351
+ Err(CommandError::InvalidCommand(_))
352
+ ));
353
+ assert!(matches!(
354
+ build_argv(" ", &[]),
355
+ Err(CommandError::InvalidCommand(_))
356
+ ));
357
+ }
358
+
359
+ #[test]
360
+ fn unbalanced_quotes_are_invalid() {
361
+ assert!(matches!(
362
+ build_argv("sh -c 'unterminated", &[]),
363
+ Err(CommandError::InvalidCommand(_))
364
+ ));
365
+ }
366
+
367
+ #[test]
368
+ fn substitutes_a_single_placeholder() {
369
+ assert_eq!(
370
+ argv("cat {path}", &[Placeholder::new("path", "a/b.json")]),
371
+ ["cat", "a/b.json"]
372
+ );
373
+ }
374
+
375
+ #[test]
376
+ fn substitutes_all_occurrences_across_and_within_tokens() {
377
+ assert_eq!(
378
+ argv(
379
+ "cp {path} {path}.bak --label={path}",
380
+ &[Placeholder::new("path", "x")]
381
+ ),
382
+ ["cp", "x", "x.bak", "--label=x"]
383
+ );
384
+ }
385
+
386
+ #[test]
387
+ fn a_substituted_value_with_spaces_stays_a_single_arg() {
388
+ assert_eq!(
389
+ argv("read {path}", &[Placeholder::new("path", "my file.json")]),
390
+ ["read", "my file.json"]
391
+ );
392
+ }
393
+
394
+ #[test]
395
+ fn substitution_is_single_pass_so_injected_braces_are_not_rescanned() {
396
+ // `{args}` carries a literal `{path}`; it must NOT be substituted again
397
+ // by the `{path}` placeholder — the injected value stays verbatim.
398
+ assert_eq!(
399
+ argv(
400
+ "run {args} {path}",
401
+ &[
402
+ Placeholder::new("args", "hello {path}"),
403
+ Placeholder::new("path", "REAL"),
404
+ ]
405
+ ),
406
+ ["run", "hello {path}", "REAL"]
407
+ );
408
+ }
409
+
410
+ #[test]
411
+ fn unknown_placeholder_is_left_literal() {
412
+ assert_eq!(
413
+ argv("echo {unknown} {path}", &[Placeholder::new("path", "p")]),
414
+ ["echo", "{unknown}", "p"]
415
+ );
416
+ }
417
+
418
+ #[test]
419
+ fn append_if_absent_appends_when_the_template_omits_the_token() {
420
+ assert_eq!(
421
+ argv("extract.py", &[Placeholder::append("path", "docs/a.md")]),
422
+ ["extract.py", "docs/a.md"]
423
+ );
424
+ }
425
+
426
+ #[test]
427
+ fn append_if_absent_does_not_append_when_the_token_is_present() {
428
+ assert_eq!(
429
+ argv(
430
+ "extract.py --file {path}",
431
+ &[Placeholder::append("path", "docs/a.md")]
432
+ ),
433
+ ["extract.py", "--file", "docs/a.md"]
434
+ );
435
+ }
436
+
437
+ #[test]
438
+ fn substitute_only_placeholder_is_dropped_when_absent() {
439
+ // `abspath` is not `append`, so omitting `{abspath}` adds nothing.
440
+ assert_eq!(
441
+ argv("run", &[Placeholder::new("abspath", "/tmp/x")]),
442
+ ["run"]
443
+ );
444
+ }
445
+
446
+ #[test]
447
+ fn extract_payload_takes_the_last_non_empty_line() {
448
+ assert_eq!(
449
+ extract_payload("log line\n[{\"a\":1}]\n"),
450
+ Some("[{\"a\":1}]".to_string())
451
+ );
452
+ }
453
+
454
+ #[test]
455
+ fn extract_payload_ignores_leading_chatter_and_trailing_blanks() {
456
+ assert_eq!(
457
+ extract_payload("starting...\ndone\nPAYLOAD\n\n \n"),
458
+ Some("PAYLOAD".to_string())
459
+ );
460
+ }
461
+
462
+ #[test]
463
+ fn extract_payload_is_none_when_all_lines_are_blank() {
464
+ assert_eq!(extract_payload(""), None);
465
+ assert_eq!(extract_payload("\n \n\t\n"), None);
466
+ }
467
+
468
+ #[test]
469
+ fn stderr_tail_passes_short_output_through_trimmed() {
470
+ assert_eq!(stderr_tail(b"\n boom \n"), "boom");
471
+ }
472
+
473
+ #[test]
474
+ fn stderr_tail_truncates_long_output_to_the_tail() {
475
+ let long = "x".repeat(3000);
476
+ let tail = stderr_tail(long.as_bytes());
477
+ assert_eq!(tail.chars().count(), 2001); // 2000 chars + the leading '…'
478
+ assert!(tail.starts_with('…'));
479
+ assert!(tail.ends_with('x'));
480
+ }
481
+
482
+ #[test]
483
+ fn placeholder_constructors_set_append_flag() {
484
+ assert!(!Placeholder::new("a", "b").append_if_absent);
485
+ assert!(Placeholder::append("a", "b").append_if_absent);
486
+ }
487
+ }
@@ -7,6 +7,8 @@
7
7
  //! bindings in this workspace can reach them, but they are not part of the
8
8
  //! stable public API.
9
9
 
10
+ /// Reusable command runner backing the command-backed events (#322).
11
+ pub mod command;
10
12
  #[doc(hidden)]
11
13
  pub mod config;
12
14
  #[doc(hidden)]
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
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes