Compare commits

..

42 Commits

Author SHA1 Message Date
Sayan Sisodiya
81e641c6ea fix plugin cache poisoning on turn startup
Co-authored-by: Codex <noreply@openai.com>
2026-03-04 20:16:07 -08:00
Sayan Sisodiya
7fe4a43713 debug linux plugin mention resolution
Co-authored-by: Codex <noreply@openai.com>
2026-03-04 20:04:36 -08:00
Sayan Sisodiya
584919cd54 tests fix 2026-03-04 19:51:41 -08:00
Sayan Sisodiya
459766b4ad whitespace 2026-03-04 19:51:41 -08:00
Sayan Sisodiya
d8d86fb24f rm capabilityindex intermediate type 2026-03-04 19:51:41 -08:00
Sayan Sisodiya
974d553983 update tool descs with plugins at tool-build time 2026-03-04 19:51:41 -08:00
Sayan Sisodiya
e0b157279f annotate tools with plugin info, inject plugin info on @mention 2026-03-04 19:51:41 -08:00
Sayan Sisodiya
3c9cb670ca enable text-based @plugin mentions 2026-03-04 19:51:41 -08:00
sayan-oai
d44398905b feat: track plugins mcps/apps and add plugin info to user_instructions (#13433)
### first half of changes, followed by #13510

Track plugin capabilities as derived summaries on `PluginLoadOutcome`
for enabled plugins with at least one skill/app/mcp.

Also add `Plugins` section to `user_instructions` injected on session
start. These introduce the plugins concept and list enabled plugins, but
do NOT currently include paths to enabled plugins or details on what
apps/mcps the plugins contain (current plan is to inject this on
@-mention). that can be adjusted in a follow up and based on evals.

### tests
Added/updated tests, confirmed locally that new `Plugins` section +
currently enabled plugins show up in `user_instructions`.
2026-03-04 19:46:13 -08:00
dependabot[bot]
be5e8fbd37 chore(deps): bump actions/upload-artifact from 6 to 7 (#13207)
Bumps
[actions/upload-artifact](https://github.com/actions/upload-artifact)
from 6 to 7.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/actions/upload-artifact/releases">actions/upload-artifact's
releases</a>.</em></p>
<blockquote>
<h2>v7.0.0</h2>
<h2>v7 What's new</h2>
<h3>Direct Uploads</h3>
<p>Adds support for uploading single files directly (unzipped). Callers
can set the new <code>archive</code> parameter to <code>false</code> to
skip zipping the file during upload. Right now, we only support single
files. The action will fail if the glob passed resolves to multiple
files. The <code>name</code> parameter is also ignored with this
setting. Instead, the name of the artifact will be the name of the
uploaded file.</p>
<h3>ESM</h3>
<p>To support new versions of the <code>@actions/*</code> packages,
we've upgraded the package to ESM.</p>
<h2>What's Changed</h2>
<ul>
<li>Add proxy integration test by <a
href="https://github.com/Link"><code>@​Link</code></a>- in <a
href="https://redirect.github.com/actions/upload-artifact/pull/754">actions/upload-artifact#754</a></li>
<li>Upgrade the module to ESM and bump dependencies by <a
href="https://github.com/danwkennedy"><code>@​danwkennedy</code></a> in
<a
href="https://redirect.github.com/actions/upload-artifact/pull/762">actions/upload-artifact#762</a></li>
<li>Support direct file uploads by <a
href="https://github.com/danwkennedy"><code>@​danwkennedy</code></a> in
<a
href="https://redirect.github.com/actions/upload-artifact/pull/764">actions/upload-artifact#764</a></li>
</ul>
<h2>New Contributors</h2>
<ul>
<li><a href="https://github.com/Link"><code>@​Link</code></a>- made
their first contribution in <a
href="https://redirect.github.com/actions/upload-artifact/pull/754">actions/upload-artifact#754</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/actions/upload-artifact/compare/v6...v7.0.0">https://github.com/actions/upload-artifact/compare/v6...v7.0.0</a></p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="bbbca2ddaa"><code>bbbca2d</code></a>
Support direct file uploads (<a
href="https://redirect.github.com/actions/upload-artifact/issues/764">#764</a>)</li>
<li><a
href="589182c5a4"><code>589182c</code></a>
Upgrade the module to ESM and bump dependencies (<a
href="https://redirect.github.com/actions/upload-artifact/issues/762">#762</a>)</li>
<li><a
href="47309c993a"><code>47309c9</code></a>
Merge pull request <a
href="https://redirect.github.com/actions/upload-artifact/issues/754">#754</a>
from actions/Link-/add-proxy-integration-tests</li>
<li><a
href="02a8460834"><code>02a8460</code></a>
Add proxy integration test</li>
<li>See full diff in <a
href="https://github.com/actions/upload-artifact/compare/v6...v7">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=actions/upload-artifact&package-manager=github_actions&previous-version=6&new-version=7)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Eric Traut <etraut@openai.com>
2026-03-04 18:32:35 -07:00
joeytrasatti-openai
22f4113ac1 Preserve persisted thread git info in resume (#13504)
## Summary
- ensure `thread.resume` reuses the stored `gitInfo` instead of
rebuilding it from the live working tree
- persist and apply thread git metadata through the resume flow and add
a regression test covering branch mismatch cases

## Testing
- Not run (not requested)
2026-03-04 17:16:43 -08:00
dependabot[bot]
95aad8719f chore(deps): bump serde_with from 3.16.1 to 3.17.0 in /codex-rs (#13209)
Bumps [serde_with](https://github.com/jonasbb/serde_with) from 3.16.1 to
3.17.0.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/jonasbb/serde_with/releases">serde_with's
releases</a>.</em></p>
<blockquote>
<h2>serde_with v3.17.0</h2>
<h3>Added</h3>
<ul>
<li>Support <code>OneOrMany</code> with <code>smallvec</code> v1 (<a
href="https://redirect.github.com/jonasbb/serde_with/issues/920">#920</a>,
<a
href="https://redirect.github.com/jonasbb/serde_with/issues/922">#922</a>)</li>
</ul>
<h3>Changed</h3>
<ul>
<li>Switch to <code>yaml_serde</code> for a maintained yaml dependency
by <a href="https://github.com/kazan417"><code>@​kazan417</code></a> (<a
href="https://redirect.github.com/jonasbb/serde_with/issues/921">#921</a>)</li>
<li>Bump MSRV to 1.82, since that is required for
<code>yaml_serde</code> dev-dependency.</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="4031878a4c"><code>4031878</code></a>
Bump version to v3.17.0 (<a
href="https://redirect.github.com/jonasbb/serde_with/issues/924">#924</a>)</li>
<li><a
href="204ae56f8b"><code>204ae56</code></a>
Bump version to v3.17.0</li>
<li><a
href="7812b5a006"><code>7812b5a</code></a>
serde_yaml 0.9 to yaml_serde 0.10 (<a
href="https://redirect.github.com/jonasbb/serde_with/issues/921">#921</a>)</li>
<li><a
href="614bd8950b"><code>614bd89</code></a>
Bump MSRV to 1.82 as required by yaml_serde</li>
<li><a
href="518d0ed787"><code>518d0ed</code></a>
Suppress RUSTSEC-2026-0009 since we don't have untrusted time input in
tests ...</li>
<li><a
href="a6579a8984"><code>a6579a8</code></a>
Suppress RUSTSEC-2026-0009 since we don't have untrusted time input in
tests</li>
<li><a
href="9d4d0696e6"><code>9d4d069</code></a>
Implement OneOrMany for smallvec_1::SmallVec (<a
href="https://redirect.github.com/jonasbb/serde_with/issues/922">#922</a>)</li>
<li><a
href="fc78243e8c"><code>fc78243</code></a>
Add changelog</li>
<li><a
href="2b8c30bf67"><code>2b8c30b</code></a>
Implement OneOrMany for smallvec_1::SmallVec</li>
<li><a
href="2d9b9a1815"><code>2d9b9a1</code></a>
Carg.lock update</li>
<li>Additional commits viewable in <a
href="https://github.com/jonasbb/serde_with/compare/v3.16.1...v3.17.0">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=serde_with&package-manager=cargo&previous-version=3.16.1&new-version=3.17.0)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Eric Traut <etraut@openai.com>
2026-03-04 18:08:26 -07:00
dependabot[bot]
14ac823aef chore(deps): bump strum_macros from 0.27.2 to 0.28.0 in /codex-rs (#13210)
Bumps [strum_macros](https://github.com/Peternator7/strum) from 0.27.2
to 0.28.0.
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/Peternator7/strum/blob/master/CHANGELOG.md">strum_macros's
changelog</a>.</em></p>
<blockquote>
<h2>0.28.0</h2>
<ul>
<li>
<p><a
href="https://redirect.github.com/Peternator7/strum/pull/461">#461</a>:
Allow any kind of passthrough attributes on
<code>EnumDiscriminants</code>.</p>
<ul>
<li>Previously only list-style attributes (e.g.
<code>#[strum_discriminants(derive(...))]</code>) were supported. Now
path-only
(e.g. <code>#[strum_discriminants(non_exhaustive)]</code>) and
name/value (e.g. <code>#[strum_discriminants(doc =
&quot;foo&quot;)]</code>)
attributes are also supported.</li>
</ul>
</li>
<li>
<p><a
href="https://redirect.github.com/Peternator7/strum/pull/462">#462</a>:
Add missing <code>#[automatically_derived]</code> to generated impls not
covered by <a
href="https://redirect.github.com/Peternator7/strum/pull/444">#444</a>.</p>
</li>
<li>
<p><a
href="https://redirect.github.com/Peternator7/strum/pull/466">#466</a>:
Bump MSRV to 1.71, required to keep up with updated <code>syn</code> and
<code>windows-sys</code> dependencies. This is a breaking change if
you're on an old version of rust.</p>
</li>
<li>
<p><a
href="https://redirect.github.com/Peternator7/strum/pull/469">#469</a>:
Use absolute paths in generated proc macro code to avoid
potential name conflicts.</p>
</li>
<li>
<p><a
href="https://redirect.github.com/Peternator7/strum/pull/465">#465</a>:
Upgrade <code>phf</code> dependency to v0.13.</p>
</li>
<li>
<p><a
href="https://redirect.github.com/Peternator7/strum/pull/473">#473</a>:
Fix <code>cargo fmt</code> / <code>clippy</code> issues and add GitHub
Actions CI.</p>
</li>
<li>
<p><a
href="https://redirect.github.com/Peternator7/strum/pull/477">#477</a>:
<code>strum::ParseError</code> now implements
<code>core::fmt::Display</code> instead
<code>std::fmt::Display</code> to make it <code>#[no_std]</code>
compatible. Note the <code>Error</code> trait wasn't available in core
until <code>1.81</code>
so <code>strum::ParseError</code> still only implements that in std.</p>
</li>
<li>
<p><a
href="https://redirect.github.com/Peternator7/strum/pull/476">#476</a>:
<strong>Breaking Change</strong> - <code>EnumString</code> now
implements <code>From&lt;&amp;str&gt;</code>
(infallible) instead of <code>TryFrom&lt;&amp;str&gt;</code> when the
enum has a <code>#[strum(default)]</code> variant. This more accurately
reflects that parsing cannot fail in that case. If you need the old
<code>TryFrom</code> behavior, you can opt back in using
<code>parse_error_ty</code> and <code>parse_error_fn</code>:</p>
<pre lang="rust"><code>#[derive(EnumString)]
#[strum(parse_error_ty = strum::ParseError, parse_error_fn =
make_error)]
pub enum Color {
    Red,
    #[strum(default)]
    Other(String),
}
<p>fn make_error(x: &amp;str) -&gt; strum::ParseError {
strum::ParseError::VariantNotFound
}
</code></pre></p>
</li>
<li>
<p><a
href="https://redirect.github.com/Peternator7/strum/pull/431">#431</a>:
Fix bug where <code>EnumString</code> ignored the
<code>parse_err_ty</code>
attribute when the enum had a <code>#[strum(default)]</code>
variant.</p>
</li>
<li>
<p><a
href="https://redirect.github.com/Peternator7/strum/pull/474">#474</a>:
EnumDiscriminants will now copy <code>default</code> over from the
original enum to the Discriminant enum.</p>
<pre lang="rust"><code>#[derive(Debug, Default, EnumDiscriminants)]
#[strum_discriminants(derive(Default))] // &lt;- Remove this in 0.28.
enum MyEnum {
    #[default] // &lt;- Will be the #[default] on the MyEnumDiscriminant
    #[strum_discriminants(default)] // &lt;- Remove this in 0.28
    Variant0,
    Variant1 { a: NonDefault },
}
</code></pre>
</li>
</ul>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="7376771128"><code>7376771</code></a>
Peternator7/0.28 (<a
href="https://redirect.github.com/Peternator7/strum/issues/475">#475</a>)</li>
<li><a
href="26e63cd964"><code>26e63cd</code></a>
Display exists in core (<a
href="https://redirect.github.com/Peternator7/strum/issues/477">#477</a>)</li>
<li><a
href="9334c728ee"><code>9334c72</code></a>
Make TryFrom and FromStr infallible if there's a default (<a
href="https://redirect.github.com/Peternator7/strum/issues/476">#476</a>)</li>
<li><a
href="0ccbbf823c"><code>0ccbbf8</code></a>
Honor parse_err_ty attribute when the enum has a default variant (<a
href="https://redirect.github.com/Peternator7/strum/issues/431">#431</a>)</li>
<li><a
href="2c9e5a9259"><code>2c9e5a9</code></a>
Automatically add Default implementation to EnumDiscriminant if it
exists on ...</li>
<li><a
href="e241243e48"><code>e241243</code></a>
Fix existing cargo fmt + clippy issues and add GH actions (<a
href="https://redirect.github.com/Peternator7/strum/issues/473">#473</a>)</li>
<li><a
href="639b67fefd"><code>639b67f</code></a>
feat: allow any kind of passthrough attributes on
<code>EnumDiscriminants</code> (<a
href="https://redirect.github.com/Peternator7/strum/issues/461">#461</a>)</li>
<li><a
href="0ea1e2d0fd"><code>0ea1e2d</code></a>
docs: Fix typo (<a
href="https://redirect.github.com/Peternator7/strum/issues/463">#463</a>)</li>
<li><a
href="36c051b910"><code>36c051b</code></a>
Upgrade <code>phf</code> to v0.13 (<a
href="https://redirect.github.com/Peternator7/strum/issues/465">#465</a>)</li>
<li><a
href="9328b38617"><code>9328b38</code></a>
Use absolute paths in proc macro (<a
href="https://redirect.github.com/Peternator7/strum/issues/469">#469</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/Peternator7/strum/compare/v0.27.2...v0.28.0">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=strum_macros&package-manager=cargo&previous-version=0.27.2&new-version=0.28.0)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Eric Traut <etraut@openai.com>
2026-03-04 17:58:58 -07:00
Won Park
229e6d0347 image-gen-event/client_processing (#13512)
enabling client-side to process with image-generation capabilities
(setting app-server)
2026-03-04 16:54:38 -08:00
dependabot[bot]
84ba9f8e74 chore(deps): bump actions/download-artifact from 7 to 8 (#13208)
Bumps
[actions/download-artifact](https://github.com/actions/download-artifact)
from 7 to 8.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/actions/download-artifact/releases">actions/download-artifact's
releases</a>.</em></p>
<blockquote>
<h2>v8.0.0</h2>
<h2>v8 - What's new</h2>
<h3>Direct downloads</h3>
<p>To support direct uploads in <code>actions/upload-artifact</code>,
the action will no longer attempt to unzip all downloaded files.
Instead, the action checks the <code>Content-Type</code> header ahead of
unzipping and skips non-zipped files. Callers wishing to download a
zipped file as-is can also set the new <code>skip-decompress</code>
parameter to <code>false</code>.</p>
<h3>Enforced checks (breaking)</h3>
<p>A previous release introduced digest checks on the download. If a
download hash didn't match the expected hash from the server, the action
would log a warning. Callers can now configure the behavior on mismatch
with the <code>digest-mismatch</code> parameter. To be secure by
default, we are now defaulting the behavior to <code>error</code> which
will fail the workflow run.</p>
<h3>ESM</h3>
<p>To support new versions of the @actions/* packages, we've upgraded
the package to ESM.</p>
<h2>What's Changed</h2>
<ul>
<li>Don't attempt to un-zip non-zipped downloads by <a
href="https://github.com/danwkennedy"><code>@​danwkennedy</code></a> in
<a
href="https://redirect.github.com/actions/download-artifact/pull/460">actions/download-artifact#460</a></li>
<li>Add a setting to specify what to do on hash mismatch and default it
to <code>error</code> by <a
href="https://github.com/danwkennedy"><code>@​danwkennedy</code></a> in
<a
href="https://redirect.github.com/actions/download-artifact/pull/461">actions/download-artifact#461</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/actions/download-artifact/compare/v7...v8.0.0">https://github.com/actions/download-artifact/compare/v7...v8.0.0</a></p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="70fc10c6e5"><code>70fc10c</code></a>
Merge pull request <a
href="https://redirect.github.com/actions/download-artifact/issues/461">#461</a>
from actions/danwkennedy/digest-mismatch-behavior</li>
<li><a
href="f258da9a50"><code>f258da9</code></a>
Add change docs</li>
<li><a
href="ccc058e5fb"><code>ccc058e</code></a>
Fix linting issues</li>
<li><a
href="bd7976ba57"><code>bd7976b</code></a>
Add a setting to specify what to do on hash mismatch and default it to
<code>error</code></li>
<li><a
href="ac21fcf45e"><code>ac21fcf</code></a>
Merge pull request <a
href="https://redirect.github.com/actions/download-artifact/issues/460">#460</a>
from actions/danwkennedy/download-no-unzip</li>
<li><a
href="15999bff51"><code>15999bf</code></a>
Add note about package bumps</li>
<li><a
href="974686ed50"><code>974686e</code></a>
Bump the version to <code>v8</code> and add release notes</li>
<li><a
href="fbe48b1d27"><code>fbe48b1</code></a>
Update test names to make it clearer what they do</li>
<li><a
href="96bf374a61"><code>96bf374</code></a>
One more test fix</li>
<li><a
href="b8c4819ef5"><code>b8c4819</code></a>
Fix skip decompress test</li>
<li>Additional commits viewable in <a
href="https://github.com/actions/download-artifact/compare/v7...v8">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=actions/download-artifact&package-manager=github_actions&previous-version=7&new-version=8)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-04 17:40:54 -07:00
Ahmed Ibrahim
7b088901c2 Log non-audio realtime events (#13516)
Improve observability of realtime conversation event handling by logging
non-audio events with payload details in the event loop, while skipping
audio-out events to reduce noise.
2026-03-04 16:30:18 -08:00
xl-openai
1e877ccdd2 plugin: support local-based marketplace.json + install endpoint. (#13422)
Support marketplace.json that points to a local file, with
```
    "source":
    {
        "source": "local",
        "path": "./plugin-1"
    },
 ```
 
 Add a new plugin/install endpoint which add the plugin to the cache folder and enable it in config.toml.
2026-03-04 19:08:18 -05:00
Ahmed Ibrahim
294079b0b1 Prefix handoff messages with role (#13505)
Format handoff context by prefixing each message with its role (for
example "user:" and "assistant:") before forwarding to the agent.
2026-03-04 15:37:31 -08:00
Michael Bolin
4907096d13 [release] temporarily use thin LTO for releases (#13506) 2026-03-04 14:10:54 -08:00
Eric Traut
f80e5d979d Notify TUI about plan mode prompts and user input requests (#13495)
Addresses #13478

Summary
- Add two new scopes for `tui.notifications` config: `plan-mode-prompt`
and `user-input-requested`.
- Add Plan Mode prompt and user-input-requested notifications to the TUI
so these events surface consistently outside of plan mode
- Add helpers and tests to ensure the new notification types publish the
right titles, summaries, and type tags for filtering
- Add prioritization mechanism to fix an existing bug where one
notification event could arbitrarily overwrite others

Testing
- Manually tested plan mode to ensure that notification appeared
2026-03-04 15:08:57 -07:00
alexsong-oai
ce139bb1af add metrics for external config import (#13501) 2026-03-04 13:59:50 -08:00
Owen Lin
8dfd654196 feat(app-server-test-client): OTEL setup for tracing (#13493)
### Overview
This PR:
- Updates `app-server-test-client` to load OTEL settings from
`$CODEX_HOME/config.toml` and initializes its own OTEL provider.
- Add real client root spans to app-server test client traces.

This updates `codex-app-server-test-client` so its Datadog traces
reflect the full client-driven flow instead of a set of server spans
stitched together under a synthetic parent.

Before this change, the test client generated a fake `traceparent` once
and reused it for every JSON-RPC request. That kept the requests in one
trace, but there was no real client span at the top, so Datadog ended up
showing the sequence in a slightly misleading way, where all RPCs were
anchored under `initialize`.

Now the test client:
- loads OTEL settings from the normal Codex config path, including
`$CODEX_HOME/config.toml` and existing --config overrides
- initializes tracing the same way other Codex binaries do when trace
export is enabled
- creates a real client root span for each scripted command
- creates per-request client spans for JSON-RPC methods like
`initialize`, `thread/start`, and `turn/start`
- injects W3C trace context from the current client span into
request.trace instead of reusing a fabricated carrier

This gives us a cleaner trace shape in Datadog:
- one trace URL for the whole scripted flow
- a visible client root span
- proper client/server parent-child relationships for each app-server
request
2026-03-04 13:30:09 -08:00
jif-oai
2322e49549 feat: external artifacts builder (#13485)
This PR reverts the built-in artifact render while a decision is being
reached. No impact expected on any features
2026-03-04 20:22:34 +00:00
Felipe Coury
98923e53cc fix(tui): decode ANSI alpha-channel encoding in syntax themes (#13382)
## Problem

The `ansi`, `base16`, and `base16-256` syntax themes are designed to
emit ANSI palette colors so that highlighted code respects the user's
terminal color scheme. Syntect encodes this intent in the alpha channel
of its `Color` struct — a convention shared with `bat` — but
`convert_style` was ignoring it entirely, treating every foreground
color as raw RGB. This caused ANSI-family themes to produce hard-coded
RGB values (e.g. `Rgb(0x02, 0, 0)` instead of `Green`), defeating their
purpose and rendering them as near-invisible dark colors on most
terminals.

Reported in #12890.

## Mental model

Syntect themes use a compact encoding in their `Color` struct:

| `alpha` | Meaning of `r` | Mapped to |
|---------|----------------|-----------|
| `0x00` | ANSI palette index (0–255) | `RtColor::Black`…`Gray` for 0–7,
`Indexed(n)` for 8–255 |
| `0x01` | Unused (sentinel) | `None` — inherit terminal default fg/bg |
| `0xFF` | True RGB red channel | `RtColor::Rgb(r, g, b)` |
| other | Unexpected | `RtColor::Rgb(r, g, b)` (silent fallback) |

This encoding is a bat convention that three bundled themes rely on. The
new `convert_syntect_color` function decodes it; `ansi_palette_color`
maps indices 0–7 to ratatui's named ANSI variants.

| macOS - Dark | macOS - Light | Windows - ansi | Windows - base16 |
|---|---|---|---|
| <img width="1064" height="1205" alt="macos-dark"
src="https://github.com/user-attachments/assets/f03d92fb-b44b-4939-b2b9-503fde133811"
/> | <img width="1073" height="1227" alt="macos-light"
src="https://github.com/user-attachments/assets/2ecb2089-73b5-4676-bed8-e4e6794250b4"
/> |
![windows-ansi](https://github.com/user-attachments/assets/d41029e6-ffd3-454e-ab72-6751607e5d5c)
|
![windows-base16](https://github.com/user-attachments/assets/b48aafcc-0196-4977-8ee1-8f8eaddd1698)
|

## Non-goals

- Background color decoding — we intentionally skip backgrounds to
preserve the terminal's own background. The decoder supports it, but
`convert_style` does not apply it.
- Italic/underline changes — those remain suppressed as before.
- Custom `.tmTheme` support for ANSI encoding — only the bundled themes
use this convention.

## Tradeoffs

- The alpha-channel encoding is an undocumented bat/syntect convention,
not a formal spec. We match bat's behavior exactly, trading formality
for ecosystem compatibility.
- Indices 0–7 are mapped to ratatui's named variants (`Black`, `Red`, …,
`Gray`) rather than `Indexed(0)`…`Indexed(7)`. This lets terminals apply
bold/bright semantics to named colors, which is the expected behavior
for ANSI themes, but means the two representations are not perfectly
round-trippable.

## Architecture

All changes are in `codex-rs/tui/src/render/highlight.rs`, within the
style-conversion layer between syntect and ratatui:

```
syntect::highlighting::Color
  └─ convert_syntect_color(color)  [NEW — alpha-dispatch]
       ├─ a=0x00 → ansi_palette_color()  [NEW — index→named/indexed]
       ├─ a=0x01 → None (terminal default)
       ├─ a=0xFF → Rgb(r,g,b) (standard opaque path)
       └─ other  → Rgb(r,g,b) (silent fallback)
```

`convert_style` delegates foreground mapping to `convert_syntect_color`
instead of inlining the `Rgb(r,g,b)` conversion. The core highlighter is
refactored into `highlight_to_line_spans_with_theme` (accepts an
explicit theme reference) so tests can highlight against specific themes
without mutating process-global state.

### ANSI-family theme contract

The ANSI-family themes (`ansi`, `base16`, `base16-256`) rely on upstream
alpha-channel encoding from two_face/syntect. We intentionally do
**not** validate this contract at runtime — if the upstream format
changes, the `ansi_themes_use_only_ansi_palette_colors` test catches it
at build time, long before it reaches users. A runtime warning would be
unactionable noise.

### Warning copy cleanup

User-facing warning messages were rewritten for clarity:
- Removed internal jargon ("alpha-encoded ANSI color markers", "RGB
fallback semantics", "persisted override config")
- Dropped "syntax" prefix from "syntax theme" — users just think "theme"
- Downgraded developer-only diagnostics (duplicate override, resolve
fallback) from `warn` to `debug`

## Observability

- The `ansi_themes_use_only_ansi_palette_colors` test enforces the
ANSI-family contract at build time.
- The snapshot test provides a regression tripwire for palette color
output.
- User-facing warnings are limited to actionable issues: unknown theme
names and invalid custom `.tmTheme` files.

## Tests

- **Unit tests for each alpha branch:** `alpha=0x00` with low index
(named color), `alpha=0x00` with high index (`Indexed`), `alpha=0x01`
(terminal default), unexpected alpha (falls back to RGB), ANSI white →
Gray mapping.
- **Integration test:**
`ansi_family_themes_use_terminal_palette_colors_not_rgb` — highlights a
Rust snippet with each ANSI-family theme and asserts zero `Rgb`
foreground colors appear.
- **Snapshot test:** `ansi_family_foreground_palette_snapshot` — records
the exact set of unique foreground colors each ANSI-family theme
produces, guarding against regressions.
- **Warning validation tests:** verify user-facing warnings for missing
custom themes, invalid `.tmTheme` files, and bundled theme resolution.

## Test plan

- [ ] `cargo test -p codex-tui` passes all new and existing tests
- [ ] Select `ansi`, `base16`, or `base16-256` theme and verify code
blocks render with terminal palette colors (not near-black RGB)
- [ ] Select a standard RGB theme (e.g. `dracula`) and verify no
regression in color output
2026-03-04 12:03:34 -08:00
pash-openai
b200a5f45b [tui] Update Fast slash command description (#13458)
## Summary
- update the /fast slash command description to mention fastest
inference
- mention the 3X plan usage tradeoff in the help copy

## Testing
- cargo test -p codex-tui slash_command (currently blocked by an
unrelated latest-main codex-tui compile error in chatwidget.rs:
refresh_queued_user_messages missing)

---------

Co-authored-by: Codex <noreply@openai.com>
2026-03-04 19:30:51 +00:00
Val Kharitonov
26f4b8e2f1 remove serviceTier from app-server examples (#13489)
Documentation-only
2026-03-04 19:12:40 +00:00
Owen Lin
27724f6ead feat(core, tracing): add a span representing a turn (#13424)
This is PR 3 of the app-server tracing rollout.

PRs https://github.com/openai/codex/pull/13285 and
https://github.com/openai/codex/pull/13368 gave us inbound request spans
in app-server and propagated trace context through Submission. This
change finishes the next piece in core: when a request actually starts a
turn, we now create a core-owned long-lived span that stays open for the
real lifetime of the turn.

What changed:
- `Session::spawn_task` can now optionally create a long-lived turn span
and run the spawned task inside it
- `turn/start` uses that path, so normal turn execution stays under a
single core-owned span after the async handoff
- `review/start` uses the same pattern
- added a unit test that verifies the spawned turn task inherits the
submission dispatch trace ancestry

**Why**
The app-server request span is intentionally short-lived. Once work
crosses into core, we still want one span that covers the actual
execution window until completion or interruption. This keeps that
ownership where it belongs: in the layer that owns the runtime
lifecycle.
2026-03-04 11:09:17 -08:00
iceweasel-oai
54a1c81d73 allow apps to specify cwd for sandbox setup. (#13484)
The electron app doesn't start up the app-server in a particular
workspace directory.
So sandbox setup happens in the app-installed directory instead of the
project workspace.

This allows the app do specify the workspace cwd so that the sandbox
setup actually sets up the ACLs instead of exiting fast and then having
the first shell command be slow.
2026-03-04 10:54:30 -08:00
Alex Daley
8a59386273 add new scopes to login (#12383)
Validated login + refresh flows. Removing scopes from the refresh
request until we have upgrade flow in place. Confirmed that tokens
refresh with existing scopes.
2026-03-04 16:41:54 +00:00
jif-oai
f72ab43fd1 feat: memories in workspace write (#13467) 2026-03-04 13:00:26 +00:00
jif-oai
df619474f5 nit: citation prompt (#13468) 2026-03-04 13:00:11 +00:00
jif-oai
e07eaff0d3 feat: add metric for per-turn tool count and add tmp_mem flag (#13456) 2026-03-04 11:25:58 +00:00
jif-oai
bda3c49dc4 feat: disable request input on sub agent (#13460)
https://github.com/openai/codex/issues/13289
2026-03-04 11:25:49 +00:00
jif-oai
e6b2e3a9f7 fix: bad merge (#13461) 2026-03-04 11:00:48 +00:00
jif-oai
e4a202ea52 fix: pending messages in /agent (#13240) 2026-03-04 10:17:29 +00:00
jif-oai
49634b7f9c add metric for per-turn token usage (#13454) 2026-03-04 10:17:25 +00:00
jif-oai
a4ad101125 feat: ordinal nick name (#13412) 2026-03-04 09:41:29 +00:00
jif-oai
932ff28183 feat: better multi-agent prompt (#13404) 2026-03-04 09:41:20 +00:00
Won Park
fa2306b303 image-gen-core (#13290)
Core tool-calling for image-gen, handles requesting and receiving logic
for images using response API
2026-03-03 23:11:28 -08:00
Val Kharitonov
4f6c4bb143 support 'flex' tier in app-server in addition to 'fast' (#13391) 2026-03-03 22:46:05 -08:00
Michael Bolin
7134220f3c core: box wrapper futures to reduce stack pressure (#13429)
Follow-up to [#13388](https://github.com/openai/codex/pull/13388). This
uses the same general fix pattern as
[#12421](https://github.com/openai/codex/pull/12421), but in the
`codex-core` compact/resume/fork path.

## Why

`compact_resume_after_second_compaction_preserves_history` started
overflowing the stack on Windows CI after `#13388`.

The important part is that this was not a compaction-recursion bug. The
test exercises a path with several thin `async fn` wrappers around much
larger thread-spawn, resume, and fork futures. When one `async fn`
awaits another inline, the outer future stores the callee future as part
of its own state machine. In a long wrapper chain, that means a caller
can accidentally inline a lot more state than the source code suggests.

That is exactly what was happening here:

- `ThreadManager` convenience methods such as `start_thread`,
`resume_thread_from_rollout`, and `fork_thread` were inlining the larger
spawn/resume futures beneath them.
- `core_test_support::test_codex` added another wrapper layer on top of
those same paths.
- `compact_resume_fork` adds a few more helpers, and this particular
test drives the resume/fork path multiple times.

On Windows, that was enough to push both the libtest thread and Tokio
worker threads over the edge. The previous 8 MiB test-thread workaround
proved the failure was stack-related, but it did not address the
underlying future size.

## How This Was Debugged

The useful debugging pattern here was to turn the CI-only failure into a
local low-stack repro.

1. First, remove the explicit large-stack harness so the test runs on
the normal `#[tokio::test]` path.
2. Build the test binary normally.
3. Re-run the already-built `tests/all` binary directly with
progressively smaller `RUST_MIN_STACK` values.

Running the built binary directly matters: it keeps the reduced stack
size focused on the test process instead of also applying it to `cargo`
and `rustc`.

That made it possible to answer two questions quickly:

- Does the failure still reproduce without the workaround? Yes.
- Does boxing the wrapper futures actually buy back stack headroom? Also
yes.

After this change, the built test binary passes with
`RUST_MIN_STACK=917504` and still overflows at `786432`, which is enough
evidence to justify removing the explicit 8 MiB override while keeping a
deterministic low-stack repro for future debugging.

If we hit a similar issue again, the first places to inspect are thin
`async fn` wrappers that mostly forward into a much larger async
implementation.

## `Box::pin()` Primer

`async fn` compiles into a state machine. If a wrapper does this:

```rust
async fn wrapper() {
    inner().await;
}
```

then `wrapper()` stores the full `inner()` future inline as part of its
own state.

If the wrapper instead does this:

```rust
async fn wrapper() {
    Box::pin(inner()).await;
}
```

then the child future lives on the heap, and the outer future only
stores a pinned pointer to it. That usually trades one allocation for a
substantially smaller outer future, which is exactly the tradeoff we
want when the problem is stack pressure rather than raw CPU time.

Useful references:

-
[`Box::pin`](https://doc.rust-lang.org/std/boxed/struct.Box.html#method.pin)
- [Async book:
Pinning](https://rust-lang.github.io/async-book/04_pinning/01_chapter.html)

## What Changed

- Boxed the wrapper futures in `core/src/thread_manager.rs` around
`start_thread`, `resume_thread_from_rollout`, `fork_thread`, and the
corresponding `ThreadManagerState` spawn helpers so callers no longer
inline the full spawn/resume state machine through multiple layers.
- Boxed the matching test-only wrapper futures in
`core/tests/common/test_codex.rs` and
`core/tests/suite/compact_resume_fork.rs`, which sit directly on top of
the same path.
- Restored `compact_resume_after_second_compaction_preserves_history` in
`core/tests/suite/compact_resume_fork.rs` to a normal `#[tokio::test]`
and removed the explicit `TEST_STACK_SIZE_BYTES` thread/runtime sizing.
- Simplified a tiny helper in `compact_resume_fork` by making
`fetch_conversation_path()` synchronous, which removes one more
unnecessary future layer from the test path.

## Verification

- `cargo test -p codex-core --test all
suite::compact_resume_fork::compact_resume_after_second_compaction_preserves_history
-- --exact --nocapture`
- `cargo test -p codex-core --test all suite::compact_resume_fork --
--nocapture`
- Re-ran the built `codex-core` `tests/all` binary directly with reduced
stack sizes:
  - `RUST_MIN_STACK=917504` passes
  - `RUST_MIN_STACK=786432` still overflows
- `cargo test -p codex-core`
- Still fails locally in unrelated existing integration areas that
expect the `codex` / `test_stdio_server` binaries or hit the existing
`search_tool` wiremock mismatches.
2026-03-04 05:44:52 +00:00
Celia Chen
d622bff384 chore: Nest skill and protocol network permissions under network.enabled (#13427)
## Summary

Changes the permission profile shape from a bare network boolean to a
nested object.

Before:

```yaml
permissions:
  network: true
```

After:

```yaml
permissions:
  network:
    enabled: true
```

This also updates the shared Rust and app-server protocol types so
`PermissionProfile.network` is no longer `Option<bool>`, but
`Option<NetworkPermissions>` with `enabled: Option<bool>`.

## What Changed

- Updated `PermissionProfile` in `codex-rs/protocol/src/models.rs`:
- `pub network: Option<bool>` -> `pub network:
Option<NetworkPermissions>`
- Added `NetworkPermissions` with:
  - `pub enabled: Option<bool>`
- Changed emptiness semantics so `network` is only considered empty when
`enabled` is `None`
- Updated skill metadata parsing to accept `permissions.network.enabled`
- Updated core permission consumers to read
`network.enabled.unwrap_or(false)` where a concrete boolean is needed
- Updated app-server v2 protocol types and regenerated schema/TypeScript
outputs
- Updated docs to mention `additionalPermissions.network.enabled`
2026-03-03 20:57:29 -08:00
207 changed files with 9774 additions and 27817 deletions

View File

@@ -47,7 +47,7 @@ jobs:
echo "pack_output=$PACK_OUTPUT" >> "$GITHUB_OUTPUT"
- name: Upload staged npm package artifact
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v7
with:
name: codex-npm-staging
path: ${{ steps.stage_npm_package.outputs.pack_output }}

View File

@@ -392,7 +392,7 @@ jobs:
- name: Upload Cargo timings (clippy)
if: always()
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v7
with:
name: cargo-timings-rust-ci-clippy-${{ matrix.target }}-${{ matrix.profile }}
path: codex-rs/target/**/cargo-timings/cargo-timing.html
@@ -605,7 +605,7 @@ jobs:
- name: Upload Cargo timings (nextest)
if: always()
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v7
with:
name: cargo-timings-rust-ci-nextest-${{ matrix.target }}-${{ matrix.profile }}
path: codex-rs/target/**/cargo-timings/cargo-timing.html

View File

@@ -92,7 +92,7 @@ jobs:
cargo build --target ${{ matrix.target }} --release --timings ${{ matrix.build_args }}
- name: Upload Cargo timings
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v7
with:
name: cargo-timings-rust-release-windows-${{ matrix.target }}-${{ matrix.bundle }}
path: codex-rs/target/**/cargo-timings/cargo-timing.html
@@ -112,7 +112,7 @@ jobs:
fi
- name: Upload Windows binaries
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v7
with:
name: windows-binaries-${{ matrix.target }}-${{ matrix.bundle }}
path: |
@@ -150,13 +150,13 @@ jobs:
- uses: actions/checkout@v6
- name: Download prebuilt Windows primary binaries
uses: actions/download-artifact@v7
uses: actions/download-artifact@v8
with:
name: windows-binaries-${{ matrix.target }}-primary
path: codex-rs/target/${{ matrix.target }}/release
- name: Download prebuilt Windows helper binaries
uses: actions/download-artifact@v7
uses: actions/download-artifact@v8
with:
name: windows-binaries-${{ matrix.target }}-helpers
path: codex-rs/target/${{ matrix.target }}/release
@@ -257,7 +257,7 @@ jobs:
"${GITHUB_WORKSPACE}/.github/workflows/zstd" -T0 -19 "$dest/$base"
done
- uses: actions/upload-artifact@v6
- uses: actions/upload-artifact@v7
with:
name: ${{ matrix.target }}
path: |

View File

@@ -57,7 +57,9 @@ jobs:
run:
working-directory: codex-rs
env:
CARGO_PROFILE_RELEASE_LTO: ${{ contains(github.ref_name, '-alpha') && 'thin' || 'fat' }}
# 2026-03-04: temporarily change releases to use thin LTO because
# Ubuntu ARM is timing out at 60 minutes.
CARGO_PROFILE_RELEASE_LTO: ${{ contains(github.ref_name, '-alpha') && 'thin' || 'thin' }}
strategy:
fail-fast: false
@@ -211,10 +213,11 @@ jobs:
- name: Cargo build
shell: bash
run: |
echo "CARGO_PROFILE_RELEASE_LTO: ${CARGO_PROFILE_RELEASE_LTO}"
cargo build --target ${{ matrix.target }} --release --timings --bin codex --bin codex-responses-api-proxy
- name: Upload Cargo timings
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v7
with:
name: cargo-timings-rust-release-${{ matrix.target }}
path: codex-rs/target/**/cargo-timings/cargo-timing.html
@@ -353,7 +356,7 @@ jobs:
zstd -T0 -19 --rm "$dest/$base"
done
- uses: actions/upload-artifact@v6
- uses: actions/upload-artifact@v7
with:
name: ${{ matrix.target }}
# Upload the per-binary .zst files as well as the new .tar.gz
@@ -417,7 +420,7 @@ jobs:
echo "path=${notes_path}" >> "${GITHUB_OUTPUT}"
- uses: actions/download-artifact@v7
- uses: actions/download-artifact@v8
with:
path: dist

View File

@@ -158,7 +158,7 @@ jobs:
mkdir -p "$dest"
cp bash "$dest/bash"
- uses: actions/upload-artifact@v6
- uses: actions/upload-artifact@v7
with:
name: shell-tool-mcp-bash-${{ matrix.target }}-${{ matrix.variant }}
path: artifacts/**
@@ -199,7 +199,7 @@ jobs:
mkdir -p "$dest"
cp bash "$dest/bash"
- uses: actions/upload-artifact@v6
- uses: actions/upload-artifact@v7
with:
name: shell-tool-mcp-bash-${{ matrix.target }}-${{ matrix.variant }}
path: artifacts/**
@@ -325,7 +325,7 @@ jobs:
grep -Fx "smoke-zsh" "$tmpdir/stdout.txt"
grep -Fx "/bin/echo" "$tmpdir/wrapper.log"
- uses: actions/upload-artifact@v6
- uses: actions/upload-artifact@v7
with:
name: shell-tool-mcp-zsh-${{ matrix.target }}-${{ matrix.variant }}
path: artifacts/**
@@ -403,7 +403,7 @@ jobs:
grep -Fx "smoke-zsh" "$tmpdir/stdout.txt"
grep -Fx "/bin/echo" "$tmpdir/wrapper.log"
- uses: actions/upload-artifact@v6
- uses: actions/upload-artifact@v7
with:
name: shell-tool-mcp-zsh-${{ matrix.target }}-${{ matrix.variant }}
path: artifacts/**
@@ -441,7 +441,7 @@ jobs:
run: pnpm --filter @openai/codex-shell-tool-mcp run build
- name: Download build artifacts
uses: actions/download-artifact@v7
uses: actions/download-artifact@v8
with:
path: artifacts
@@ -500,7 +500,7 @@ jobs:
filename=$(PACK_INFO="$pack_info" node -e 'const data = JSON.parse(process.env.PACK_INFO); console.log(data[0].filename);')
mv "dist/npm/${filename}" "dist/npm/codex-shell-tool-mcp-npm-${PACKAGE_VERSION}.tgz"
- uses: actions/upload-artifact@v6
- uses: actions/upload-artifact@v7
with:
name: codex-shell-tool-mcp-npm
path: dist/npm/codex-shell-tool-mcp-npm-${{ env.PACKAGE_VERSION }}.tgz
@@ -529,7 +529,7 @@ jobs:
run: npm install -g npm@latest
- name: Download npm tarball
uses: actions/download-artifact@v7
uses: actions/download-artifact@v8
with:
name: codex-shell-tool-mcp-npm
path: dist/npm

50
MODULE.bazel.lock generated

File diff suppressed because one or more lines are too long

724
codex-rs/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -33,8 +33,6 @@ members = [
"mcp-server",
"network-proxy",
"ollama",
"artifact-presentation",
"artifact-spreadsheet",
"process-hardening",
"protocol",
"rmcp-client",
@@ -66,6 +64,8 @@ members = [
"state",
"codex-experimental-api-macros",
"test-macros",
"package-manager",
"artifacts",
]
resolver = "2"
@@ -83,6 +83,8 @@ license = "Apache-2.0"
app_test_support = { path = "app-server/tests/common" }
codex-ansi-escape = { path = "ansi-escape" }
codex-api = { path = "codex-api" }
codex-artifacts = { path = "artifacts" }
codex-package-manager = { path = "package-manager" }
codex-app-server = { path = "app-server" }
codex-app-server-protocol = { path = "app-server-protocol" }
codex-app-server-test-client = { path = "app-server-test-client" }
@@ -111,8 +113,6 @@ codex-mcp-server = { path = "mcp-server" }
codex-network-proxy = { path = "network-proxy" }
codex-ollama = { path = "ollama" }
codex-otel = { path = "otel" }
codex-artifact-presentation = { path = "artifact-presentation" }
codex-artifact-spreadsheet = { path = "artifact-spreadsheet" }
codex-process-hardening = { path = "process-hardening" }
codex-protocol = { path = "protocol" }
codex-responses-api-proxy = { path = "responses-api-proxy" }
@@ -181,6 +181,7 @@ encoding_rs = "0.8.35"
env-flags = "0.1.1"
env_logger = "0.11.9"
eventsource-stream = "0.2.3"
flate2 = "1.1.4"
futures = { version = "0.3", default-features = false }
gethostname = "1.1.0"
globset = "0.4"
@@ -219,7 +220,6 @@ owo-colors = "4.3.0"
path-absolutize = "3.1.1"
pathdiff = "0.2"
portable-pty = "0.9.0"
ppt-rs = "0.2.6"
predicates = "3"
pretty_assertions = "1.4.1"
pulldown-cmark = "0.10"
@@ -242,7 +242,7 @@ sentry = "0.46.0"
serde = "1"
serde_json = "1"
serde_path_to_error = "0.1.20"
serde_with = "3.16"
serde_with = "3.17"
serde_yaml = "0.9"
serial_test = "3.2.0"
sha1 = "0.10.6"
@@ -262,11 +262,12 @@ sqlx = { version = "0.8.6", default-features = false, features = [
] }
starlark = "0.13.0"
strum = "0.27.2"
strum_macros = "0.27.2"
strum_macros = "0.28.0"
supports-color = "3.0.2"
syntect = "5"
sys-locale = "0.3.2"
tempfile = "3.23.0"
tar = "0.4.44"
test-log = "0.2.19"
textwrap = "0.16.2"
thiserror = "2.0.17"
@@ -353,8 +354,7 @@ ignored = [
"icu_provider",
"openssl-sys",
"codex-utils-readiness",
"codex-secrets",
"codex-artifact-spreadsheet"
"codex-secrets"
]
[profile.release]

View File

@@ -88,6 +88,7 @@ codex --sandbox danger-full-access
```
The same setting can be persisted in `~/.codex/config.toml` via the top-level `sandbox_mode = "MODE"` key, e.g. `sandbox_mode = "workspace-write"`.
In `workspace-write`, Codex also includes `~/.codex/memories` in its writable roots so memory maintenance does not require an extra approval.
## Code Organization

View File

@@ -951,6 +951,27 @@
],
"type": "string"
},
"PluginInstallParams": {
"properties": {
"cwd": {
"type": [
"string",
"null"
]
},
"marketplaceName": {
"type": "string"
},
"pluginName": {
"type": "string"
}
},
"required": [
"marketplaceName",
"pluginName"
],
"type": "object"
},
"ProductSurface": {
"enum": [
"chatgpt",
@@ -1416,6 +1437,40 @@
"title": "WebSearchCallResponseItem",
"type": "object"
},
{
"properties": {
"id": {
"type": "string"
},
"result": {
"type": "string"
},
"revised_prompt": {
"type": [
"string",
"null"
]
},
"status": {
"type": "string"
},
"type": {
"enum": [
"image_generation_call"
],
"title": "ImageGenerationCallResponseItemType",
"type": "string"
}
},
"required": [
"id",
"result",
"status",
"type"
],
"title": "ImageGenerationCallResponseItem",
"type": "object"
},
{
"properties": {
"ghost_commit": {
@@ -1728,7 +1783,8 @@
},
"ServiceTier": {
"enum": [
"fast"
"fast",
"flex"
],
"type": "string"
},
@@ -2829,6 +2885,12 @@
},
"WindowsSandboxSetupStartParams": {
"properties": {
"cwd": {
"type": [
"string",
"null"
]
},
"mode": {
"$ref": "#/definitions/WindowsSandboxSetupMode"
}
@@ -3298,6 +3360,30 @@
"title": "Skills/config/writeRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
"plugin/install"
],
"title": "Plugin/installRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/PluginInstallParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "Plugin/installRequest",
"type": "object"
},
{
"properties": {
"id": {

View File

@@ -65,6 +65,17 @@
},
"type": "object"
},
"AdditionalNetworkPermissions": {
"properties": {
"enabled": {
"type": [
"boolean",
"null"
]
}
},
"type": "object"
},
"AdditionalPermissionProfile": {
"properties": {
"fileSystem": {
@@ -88,9 +99,13 @@
]
},
"network": {
"type": [
"boolean",
"null"
"anyOf": [
{
"$ref": "#/definitions/AdditionalNetworkPermissions"
},
{
"type": "null"
}
]
}
},

View File

@@ -1387,6 +1387,60 @@
"title": "WebSearchEndEventMsg",
"type": "object"
},
{
"properties": {
"call_id": {
"type": "string"
},
"type": {
"enum": [
"image_generation_begin"
],
"title": "ImageGenerationBeginEventMsgType",
"type": "string"
}
},
"required": [
"call_id",
"type"
],
"title": "ImageGenerationBeginEventMsg",
"type": "object"
},
{
"properties": {
"call_id": {
"type": "string"
},
"result": {
"type": "string"
},
"revised_prompt": {
"type": [
"string",
"null"
]
},
"status": {
"type": "string"
},
"type": {
"enum": [
"image_generation_end"
],
"title": "ImageGenerationEndEventMsgType",
"type": "string"
}
},
"required": [
"call_id",
"result",
"status",
"type"
],
"title": "ImageGenerationEndEventMsg",
"type": "object"
},
{
"description": "Notification that the server is about to execute a command.",
"properties": {
@@ -3888,6 +3942,17 @@
],
"type": "string"
},
"NetworkPermissions": {
"properties": {
"enabled": {
"type": [
"boolean",
"null"
]
}
},
"type": "object"
},
"NetworkPolicyAmendment": {
"properties": {
"action": {
@@ -4052,9 +4117,13 @@
]
},
"network": {
"type": [
"boolean",
"null"
"anyOf": [
{
"$ref": "#/definitions/NetworkPermissions"
},
{
"type": "null"
}
]
}
},
@@ -4968,6 +5037,40 @@
"title": "WebSearchCallResponseItem",
"type": "object"
},
{
"properties": {
"id": {
"type": "string"
},
"result": {
"type": "string"
},
"revised_prompt": {
"type": [
"string",
"null"
]
},
"status": {
"type": "string"
},
"type": {
"enum": [
"image_generation_call"
],
"title": "ImageGenerationCallResponseItemType",
"type": "string"
}
},
"required": [
"id",
"result",
"status",
"type"
],
"title": "ImageGenerationCallResponseItem",
"type": "object"
},
{
"properties": {
"ghost_commit": {
@@ -5445,7 +5548,8 @@
},
"ServiceTier": {
"enum": [
"fast"
"fast",
"flex"
],
"type": "string"
},
@@ -5951,6 +6055,40 @@
"title": "WebSearchTurnItem",
"type": "object"
},
{
"properties": {
"id": {
"type": "string"
},
"result": {
"type": "string"
},
"revised_prompt": {
"type": [
"string",
"null"
]
},
"status": {
"type": "string"
},
"type": {
"enum": [
"ImageGeneration"
],
"title": "ImageGenerationTurnItemType",
"type": "string"
}
},
"required": [
"id",
"result",
"status",
"type"
],
"title": "ImageGenerationTurnItem",
"type": "object"
},
{
"properties": {
"id": {
@@ -7042,6 +7180,60 @@
"title": "WebSearchEndEventMsg",
"type": "object"
},
{
"properties": {
"call_id": {
"type": "string"
},
"type": {
"enum": [
"image_generation_begin"
],
"title": "ImageGenerationBeginEventMsgType",
"type": "string"
}
},
"required": [
"call_id",
"type"
],
"title": "ImageGenerationBeginEventMsg",
"type": "object"
},
{
"properties": {
"call_id": {
"type": "string"
},
"result": {
"type": "string"
},
"revised_prompt": {
"type": [
"string",
"null"
]
},
"status": {
"type": "string"
},
"type": {
"enum": [
"image_generation_end"
],
"title": "ImageGenerationEndEventMsgType",
"type": "string"
}
},
"required": [
"call_id",
"result",
"status",
"type"
],
"title": "ImageGenerationEndEventMsg",
"type": "object"
},
{
"description": "Notification that the server is about to execute a command.",
"properties": {

View File

@@ -2251,6 +2251,40 @@
"title": "ImageViewThreadItem",
"type": "object"
},
{
"properties": {
"id": {
"type": "string"
},
"result": {
"type": "string"
},
"revisedPrompt": {
"type": [
"string",
"null"
]
},
"status": {
"type": "string"
},
"type": {
"enum": [
"imageGeneration"
],
"title": "ImageGenerationThreadItemType",
"type": "string"
}
},
"required": [
"id",
"result",
"status",
"type"
],
"title": "ImageGenerationThreadItem",
"type": "object"
},
{
"properties": {
"id": {

View File

@@ -65,6 +65,17 @@
},
"type": "object"
},
"AdditionalNetworkPermissions": {
"properties": {
"enabled": {
"type": [
"boolean",
"null"
]
}
},
"type": "object"
},
"AdditionalPermissionProfile": {
"properties": {
"fileSystem": {
@@ -88,9 +99,13 @@
]
},
"network": {
"type": [
"boolean",
"null"
"anyOf": [
{
"$ref": "#/definitions/AdditionalNetworkPermissions"
},
{
"type": "null"
}
]
}
},

View File

@@ -61,6 +61,17 @@
},
"type": "object"
},
"AdditionalNetworkPermissions": {
"properties": {
"enabled": {
"type": [
"boolean",
"null"
]
}
},
"type": "object"
},
"AdditionalPermissionProfile": {
"properties": {
"fileSystem": {
@@ -84,9 +95,13 @@
]
},
"network": {
"type": [
"boolean",
"null"
"anyOf": [
{
"$ref": "#/definitions/AdditionalNetworkPermissions"
},
{
"type": "null"
}
]
}
},
@@ -811,6 +826,30 @@
"title": "Skills/config/writeRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/v2/RequestId"
},
"method": {
"enum": [
"plugin/install"
],
"title": "Plugin/installRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/v2/PluginInstallParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "Plugin/installRequest",
"type": "object"
},
{
"properties": {
"id": {
@@ -2579,6 +2618,60 @@
"title": "WebSearchEndEventMsg",
"type": "object"
},
{
"properties": {
"call_id": {
"type": "string"
},
"type": {
"enum": [
"image_generation_begin"
],
"title": "ImageGenerationBeginEventMsgType",
"type": "string"
}
},
"required": [
"call_id",
"type"
],
"title": "ImageGenerationBeginEventMsg",
"type": "object"
},
{
"properties": {
"call_id": {
"type": "string"
},
"result": {
"type": "string"
},
"revised_prompt": {
"type": [
"string",
"null"
]
},
"status": {
"type": "string"
},
"type": {
"enum": [
"image_generation_end"
],
"title": "ImageGenerationEndEventMsgType",
"type": "string"
}
},
"required": [
"call_id",
"result",
"status",
"type"
],
"title": "ImageGenerationEndEventMsg",
"type": "object"
},
{
"description": "Notification that the server is about to execute a command.",
"properties": {
@@ -5272,6 +5365,17 @@
],
"type": "string"
},
"NetworkPermissions": {
"properties": {
"enabled": {
"type": [
"boolean",
"null"
]
}
},
"type": "object"
},
"NetworkPolicyAmendment": {
"properties": {
"action": {
@@ -5428,9 +5532,13 @@
]
},
"network": {
"type": [
"boolean",
"null"
"anyOf": [
{
"$ref": "#/definitions/NetworkPermissions"
},
{
"type": "null"
}
]
}
},
@@ -7323,6 +7431,40 @@
"title": "WebSearchTurnItem",
"type": "object"
},
{
"properties": {
"id": {
"type": "string"
},
"result": {
"type": "string"
},
"revised_prompt": {
"type": [
"string",
"null"
]
},
"status": {
"type": "string"
},
"type": {
"enum": [
"ImageGeneration"
],
"title": "ImageGenerationTurnItemType",
"type": "string"
}
},
"required": [
"id",
"result",
"status",
"type"
],
"title": "ImageGenerationTurnItem",
"type": "object"
},
{
"properties": {
"id": {
@@ -10867,6 +11009,34 @@
],
"type": "string"
},
"PluginInstallParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"cwd": {
"type": [
"string",
"null"
]
},
"marketplaceName": {
"type": "string"
},
"pluginName": {
"type": "string"
}
},
"required": [
"marketplaceName",
"pluginName"
],
"title": "PluginInstallParams",
"type": "object"
},
"PluginInstallResponse": {
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "PluginInstallResponse",
"type": "object"
},
"ProductSurface": {
"enum": [
"chatgpt",
@@ -11731,6 +11901,40 @@
"title": "WebSearchCallResponseItem",
"type": "object"
},
{
"properties": {
"id": {
"type": "string"
},
"result": {
"type": "string"
},
"revised_prompt": {
"type": [
"string",
"null"
]
},
"status": {
"type": "string"
},
"type": {
"enum": [
"image_generation_call"
],
"title": "ImageGenerationCallResponseItemType",
"type": "string"
}
},
"required": [
"id",
"result",
"status",
"type"
],
"title": "ImageGenerationCallResponseItem",
"type": "object"
},
{
"properties": {
"ghost_commit": {
@@ -12104,7 +12308,8 @@
},
"ServiceTier": {
"enum": [
"fast"
"fast",
"flex"
],
"type": "string"
},
@@ -13478,6 +13683,40 @@
"title": "ImageViewThreadItem",
"type": "object"
},
{
"properties": {
"id": {
"type": "string"
},
"result": {
"type": "string"
},
"revisedPrompt": {
"type": [
"string",
"null"
]
},
"status": {
"type": "string"
},
"type": {
"enum": [
"imageGeneration"
],
"title": "ImageGenerationThreadItemType",
"type": "string"
}
},
"required": [
"id",
"result",
"status",
"type"
],
"title": "ImageGenerationThreadItem",
"type": "object"
},
{
"properties": {
"id": {
@@ -15260,6 +15499,12 @@
"WindowsSandboxSetupStartParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"cwd": {
"type": [
"string",
"null"
]
},
"mode": {
"$ref": "#/definitions/v2/WindowsSandboxSetupMode"
}

View File

@@ -1300,6 +1300,30 @@
"title": "Skills/config/writeRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
"plugin/install"
],
"title": "Plugin/installRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/PluginInstallParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "Plugin/installRequest",
"type": "object"
},
{
"properties": {
"id": {
@@ -4131,6 +4155,60 @@
"title": "WebSearchEndEventMsg",
"type": "object"
},
{
"properties": {
"call_id": {
"type": "string"
},
"type": {
"enum": [
"image_generation_begin"
],
"title": "ImageGenerationBeginEventMsgType",
"type": "string"
}
},
"required": [
"call_id",
"type"
],
"title": "ImageGenerationBeginEventMsg",
"type": "object"
},
{
"properties": {
"call_id": {
"type": "string"
},
"result": {
"type": "string"
},
"revised_prompt": {
"type": [
"string",
"null"
]
},
"status": {
"type": "string"
},
"type": {
"enum": [
"image_generation_end"
],
"title": "ImageGenerationEndEventMsgType",
"type": "string"
}
},
"required": [
"call_id",
"result",
"status",
"type"
],
"title": "ImageGenerationEndEventMsg",
"type": "object"
},
{
"description": "Notification that the server is about to execute a command.",
"properties": {
@@ -7824,6 +7902,17 @@
],
"type": "string"
},
"NetworkPermissions": {
"properties": {
"enabled": {
"type": [
"boolean",
"null"
]
}
},
"type": "object"
},
"NetworkPolicyAmendment": {
"properties": {
"action": {
@@ -8148,9 +8237,13 @@
]
},
"network": {
"type": [
"boolean",
"null"
"anyOf": [
{
"$ref": "#/definitions/NetworkPermissions"
},
{
"type": "null"
}
]
}
},
@@ -8220,6 +8313,34 @@
],
"type": "string"
},
"PluginInstallParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"cwd": {
"type": [
"string",
"null"
]
},
"marketplaceName": {
"type": "string"
},
"pluginName": {
"type": "string"
}
},
"required": [
"marketplaceName",
"pluginName"
],
"title": "PluginInstallParams",
"type": "object"
},
"PluginInstallResponse": {
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "PluginInstallResponse",
"type": "object"
},
"ProductSurface": {
"enum": [
"chatgpt",
@@ -9309,6 +9430,40 @@
"title": "WebSearchCallResponseItem",
"type": "object"
},
{
"properties": {
"id": {
"type": "string"
},
"result": {
"type": "string"
},
"revised_prompt": {
"type": [
"string",
"null"
]
},
"status": {
"type": "string"
},
"type": {
"enum": [
"image_generation_call"
],
"title": "ImageGenerationCallResponseItemType",
"type": "string"
}
},
"required": [
"id",
"result",
"status",
"type"
],
"title": "ImageGenerationCallResponseItem",
"type": "object"
},
{
"properties": {
"ghost_commit": {
@@ -10755,7 +10910,8 @@
},
"ServiceTier": {
"enum": [
"fast"
"fast",
"flex"
],
"type": "string"
},
@@ -12156,6 +12312,40 @@
"title": "ImageViewThreadItem",
"type": "object"
},
{
"properties": {
"id": {
"type": "string"
},
"result": {
"type": "string"
},
"revisedPrompt": {
"type": [
"string",
"null"
]
},
"status": {
"type": "string"
},
"type": {
"enum": [
"imageGeneration"
],
"title": "ImageGenerationThreadItemType",
"type": "string"
}
},
"required": [
"id",
"result",
"status",
"type"
],
"title": "ImageGenerationThreadItem",
"type": "object"
},
{
"properties": {
"id": {
@@ -13643,6 +13833,40 @@
"title": "WebSearchTurnItem",
"type": "object"
},
{
"properties": {
"id": {
"type": "string"
},
"result": {
"type": "string"
},
"revised_prompt": {
"type": [
"string",
"null"
]
},
"status": {
"type": "string"
},
"type": {
"enum": [
"ImageGeneration"
],
"title": "ImageGenerationTurnItemType",
"type": "string"
}
},
"required": [
"id",
"result",
"status",
"type"
],
"title": "ImageGenerationTurnItem",
"type": "object"
},
{
"properties": {
"id": {
@@ -14176,6 +14400,12 @@
"WindowsSandboxSetupStartParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"cwd": {
"type": [
"string",
"null"
]
},
"mode": {
"$ref": "#/definitions/WindowsSandboxSetupMode"
}

View File

@@ -707,7 +707,8 @@
},
"ServiceTier": {
"enum": [
"fast"
"fast",
"flex"
],
"type": "string"
},

View File

@@ -863,6 +863,40 @@
"title": "ImageViewThreadItem",
"type": "object"
},
{
"properties": {
"id": {
"type": "string"
},
"result": {
"type": "string"
},
"revisedPrompt": {
"type": [
"string",
"null"
]
},
"status": {
"type": "string"
},
"type": {
"enum": [
"imageGeneration"
],
"title": "ImageGenerationThreadItemType",
"type": "string"
}
},
"required": [
"id",
"result",
"status",
"type"
],
"title": "ImageGenerationThreadItem",
"type": "object"
},
{
"properties": {
"id": {

View File

@@ -863,6 +863,40 @@
"title": "ImageViewThreadItem",
"type": "object"
},
{
"properties": {
"id": {
"type": "string"
},
"result": {
"type": "string"
},
"revisedPrompt": {
"type": [
"string",
"null"
]
},
"status": {
"type": "string"
},
"type": {
"enum": [
"imageGeneration"
],
"title": "ImageGenerationThreadItemType",
"type": "string"
}
},
"required": [
"id",
"result",
"status",
"type"
],
"title": "ImageGenerationThreadItem",
"type": "object"
},
{
"properties": {
"id": {

View File

@@ -0,0 +1,23 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"cwd": {
"type": [
"string",
"null"
]
},
"marketplaceName": {
"type": "string"
},
"pluginName": {
"type": "string"
}
},
"required": [
"marketplaceName",
"pluginName"
],
"title": "PluginInstallParams",
"type": "object"
}

View File

@@ -0,0 +1,5 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "PluginInstallResponse",
"type": "object"
}

View File

@@ -641,6 +641,40 @@
"title": "WebSearchCallResponseItem",
"type": "object"
},
{
"properties": {
"id": {
"type": "string"
},
"result": {
"type": "string"
},
"revised_prompt": {
"type": [
"string",
"null"
]
},
"status": {
"type": "string"
},
"type": {
"enum": [
"image_generation_call"
],
"title": "ImageGenerationCallResponseItemType",
"type": "string"
}
},
"required": [
"id",
"result",
"status",
"type"
],
"title": "ImageGenerationCallResponseItem",
"type": "object"
},
{
"properties": {
"ghost_commit": {

View File

@@ -977,6 +977,40 @@
"title": "ImageViewThreadItem",
"type": "object"
},
{
"properties": {
"id": {
"type": "string"
},
"result": {
"type": "string"
},
"revisedPrompt": {
"type": [
"string",
"null"
]
},
"status": {
"type": "string"
},
"type": {
"enum": [
"imageGeneration"
],
"title": "ImageGenerationThreadItemType",
"type": "string"
}
},
"required": [
"id",
"result",
"status",
"type"
],
"title": "ImageGenerationThreadItem",
"type": "object"
},
{
"properties": {
"id": {

View File

@@ -53,7 +53,8 @@
},
"ServiceTier": {
"enum": [
"fast"
"fast",
"flex"
],
"type": "string"
}

View File

@@ -744,7 +744,8 @@
},
"ServiceTier": {
"enum": [
"fast"
"fast",
"flex"
],
"type": "string"
},
@@ -1452,6 +1453,40 @@
"title": "ImageViewThreadItem",
"type": "object"
},
{
"properties": {
"id": {
"type": "string"
},
"result": {
"type": "string"
},
"revisedPrompt": {
"type": [
"string",
"null"
]
},
"status": {
"type": "string"
},
"type": {
"enum": [
"imageGeneration"
],
"title": "ImageGenerationThreadItemType",
"type": "string"
}
},
"required": [
"id",
"result",
"status",
"type"
],
"title": "ImageGenerationThreadItem",
"type": "object"
},
{
"properties": {
"id": {

View File

@@ -1215,6 +1215,40 @@
"title": "ImageViewThreadItem",
"type": "object"
},
{
"properties": {
"id": {
"type": "string"
},
"result": {
"type": "string"
},
"revisedPrompt": {
"type": [
"string",
"null"
]
},
"status": {
"type": "string"
},
"type": {
"enum": [
"imageGeneration"
],
"title": "ImageGenerationThreadItemType",
"type": "string"
}
},
"required": [
"id",
"result",
"status",
"type"
],
"title": "ImageGenerationThreadItem",
"type": "object"
},
{
"properties": {
"id": {

View File

@@ -1215,6 +1215,40 @@
"title": "ImageViewThreadItem",
"type": "object"
},
{
"properties": {
"id": {
"type": "string"
},
"result": {
"type": "string"
},
"revisedPrompt": {
"type": [
"string",
"null"
]
},
"status": {
"type": "string"
},
"type": {
"enum": [
"imageGeneration"
],
"title": "ImageGenerationThreadItemType",
"type": "string"
}
},
"required": [
"id",
"result",
"status",
"type"
],
"title": "ImageGenerationThreadItem",
"type": "object"
},
{
"properties": {
"id": {

View File

@@ -1215,6 +1215,40 @@
"title": "ImageViewThreadItem",
"type": "object"
},
{
"properties": {
"id": {
"type": "string"
},
"result": {
"type": "string"
},
"revisedPrompt": {
"type": [
"string",
"null"
]
},
"status": {
"type": "string"
},
"type": {
"enum": [
"imageGeneration"
],
"title": "ImageGenerationThreadItemType",
"type": "string"
}
},
"required": [
"id",
"result",
"status",
"type"
],
"title": "ImageGenerationThreadItem",
"type": "object"
},
{
"properties": {
"id": {

View File

@@ -691,6 +691,40 @@
"title": "WebSearchCallResponseItem",
"type": "object"
},
{
"properties": {
"id": {
"type": "string"
},
"result": {
"type": "string"
},
"revised_prompt": {
"type": [
"string",
"null"
]
},
"status": {
"type": "string"
},
"type": {
"enum": [
"image_generation_call"
],
"title": "ImageGenerationCallResponseItemType",
"type": "string"
}
},
"required": [
"id",
"result",
"status",
"type"
],
"title": "ImageGenerationCallResponseItem",
"type": "object"
},
{
"properties": {
"ghost_commit": {
@@ -759,7 +793,8 @@
},
"ServiceTier": {
"enum": [
"fast"
"fast",
"flex"
],
"type": "string"
},

View File

@@ -744,7 +744,8 @@
},
"ServiceTier": {
"enum": [
"fast"
"fast",
"flex"
],
"type": "string"
},
@@ -1452,6 +1453,40 @@
"title": "ImageViewThreadItem",
"type": "object"
},
{
"properties": {
"id": {
"type": "string"
},
"result": {
"type": "string"
},
"revisedPrompt": {
"type": [
"string",
"null"
]
},
"status": {
"type": "string"
},
"type": {
"enum": [
"imageGeneration"
],
"title": "ImageGenerationThreadItemType",
"type": "string"
}
},
"required": [
"id",
"result",
"status",
"type"
],
"title": "ImageGenerationThreadItem",
"type": "object"
},
{
"properties": {
"id": {

View File

@@ -1215,6 +1215,40 @@
"title": "ImageViewThreadItem",
"type": "object"
},
{
"properties": {
"id": {
"type": "string"
},
"result": {
"type": "string"
},
"revisedPrompt": {
"type": [
"string",
"null"
]
},
"status": {
"type": "string"
},
"type": {
"enum": [
"imageGeneration"
],
"title": "ImageGenerationThreadItemType",
"type": "string"
}
},
"required": [
"id",
"result",
"status",
"type"
],
"title": "ImageGenerationThreadItem",
"type": "object"
},
{
"properties": {
"id": {

View File

@@ -78,7 +78,8 @@
},
"ServiceTier": {
"enum": [
"fast"
"fast",
"flex"
],
"type": "string"
}

View File

@@ -744,7 +744,8 @@
},
"ServiceTier": {
"enum": [
"fast"
"fast",
"flex"
],
"type": "string"
},
@@ -1452,6 +1453,40 @@
"title": "ImageViewThreadItem",
"type": "object"
},
{
"properties": {
"id": {
"type": "string"
},
"result": {
"type": "string"
},
"revisedPrompt": {
"type": [
"string",
"null"
]
},
"status": {
"type": "string"
},
"type": {
"enum": [
"imageGeneration"
],
"title": "ImageGenerationThreadItemType",
"type": "string"
}
},
"required": [
"id",
"result",
"status",
"type"
],
"title": "ImageGenerationThreadItem",
"type": "object"
},
{
"properties": {
"id": {

View File

@@ -1215,6 +1215,40 @@
"title": "ImageViewThreadItem",
"type": "object"
},
{
"properties": {
"id": {
"type": "string"
},
"result": {
"type": "string"
},
"revisedPrompt": {
"type": [
"string",
"null"
]
},
"status": {
"type": "string"
},
"type": {
"enum": [
"imageGeneration"
],
"title": "ImageGenerationThreadItemType",
"type": "string"
}
},
"required": [
"id",
"result",
"status",
"type"
],
"title": "ImageGenerationThreadItem",
"type": "object"
},
{
"properties": {
"id": {

View File

@@ -1215,6 +1215,40 @@
"title": "ImageViewThreadItem",
"type": "object"
},
{
"properties": {
"id": {
"type": "string"
},
"result": {
"type": "string"
},
"revisedPrompt": {
"type": [
"string",
"null"
]
},
"status": {
"type": "string"
},
"type": {
"enum": [
"imageGeneration"
],
"title": "ImageGenerationThreadItemType",
"type": "string"
}
},
"required": [
"id",
"result",
"status",
"type"
],
"title": "ImageGenerationThreadItem",
"type": "object"
},
{
"properties": {
"id": {

View File

@@ -977,6 +977,40 @@
"title": "ImageViewThreadItem",
"type": "object"
},
{
"properties": {
"id": {
"type": "string"
},
"result": {
"type": "string"
},
"revisedPrompt": {
"type": [
"string",
"null"
]
},
"status": {
"type": "string"
},
"type": {
"enum": [
"imageGeneration"
],
"title": "ImageGenerationThreadItemType",
"type": "string"
}
},
"required": [
"id",
"result",
"status",
"type"
],
"title": "ImageGenerationThreadItem",
"type": "object"
},
{
"properties": {
"id": {

View File

@@ -305,7 +305,8 @@
},
"ServiceTier": {
"enum": [
"fast"
"fast",
"flex"
],
"type": "string"
},

View File

@@ -977,6 +977,40 @@
"title": "ImageViewThreadItem",
"type": "object"
},
{
"properties": {
"id": {
"type": "string"
},
"result": {
"type": "string"
},
"revisedPrompt": {
"type": [
"string",
"null"
]
},
"status": {
"type": "string"
},
"type": {
"enum": [
"imageGeneration"
],
"title": "ImageGenerationThreadItemType",
"type": "string"
}
},
"required": [
"id",
"result",
"status",
"type"
],
"title": "ImageGenerationThreadItem",
"type": "object"
},
{
"properties": {
"id": {

View File

@@ -977,6 +977,40 @@
"title": "ImageViewThreadItem",
"type": "object"
},
{
"properties": {
"id": {
"type": "string"
},
"result": {
"type": "string"
},
"revisedPrompt": {
"type": [
"string",
"null"
]
},
"status": {
"type": "string"
},
"type": {
"enum": [
"imageGeneration"
],
"title": "ImageGenerationThreadItemType",
"type": "string"
}
},
"required": [
"id",
"result",
"status",
"type"
],
"title": "ImageGenerationThreadItem",
"type": "object"
},
{
"properties": {
"id": {

View File

@@ -10,6 +10,12 @@
}
},
"properties": {
"cwd": {
"type": [
"string",
"null"
]
},
"mode": {
"$ref": "#/definitions/WindowsSandboxSetupMode"
}

View File

@@ -22,6 +22,7 @@ import type { ListMcpServerStatusParams } from "./v2/ListMcpServerStatusParams";
import type { LoginAccountParams } from "./v2/LoginAccountParams";
import type { McpServerOauthLoginParams } from "./v2/McpServerOauthLoginParams";
import type { ModelListParams } from "./v2/ModelListParams";
import type { PluginInstallParams } from "./v2/PluginInstallParams";
import type { ReviewStartParams } from "./v2/ReviewStartParams";
import type { SkillsConfigWriteParams } from "./v2/SkillsConfigWriteParams";
import type { SkillsListParams } from "./v2/SkillsListParams";
@@ -48,4 +49,4 @@ import type { WindowsSandboxSetupStartParams } from "./v2/WindowsSandboxSetupSta
/**
* Request from the client to the server.
*/
export type ClientRequest ={ "method": "initialize", id: RequestId, params: InitializeParams, } | { "method": "thread/start", id: RequestId, params: ThreadStartParams, } | { "method": "thread/resume", id: RequestId, params: ThreadResumeParams, } | { "method": "thread/fork", id: RequestId, params: ThreadForkParams, } | { "method": "thread/archive", id: RequestId, params: ThreadArchiveParams, } | { "method": "thread/unsubscribe", id: RequestId, params: ThreadUnsubscribeParams, } | { "method": "thread/name/set", id: RequestId, params: ThreadSetNameParams, } | { "method": "thread/metadata/update", id: RequestId, params: ThreadMetadataUpdateParams, } | { "method": "thread/unarchive", id: RequestId, params: ThreadUnarchiveParams, } | { "method": "thread/compact/start", id: RequestId, params: ThreadCompactStartParams, } | { "method": "thread/rollback", id: RequestId, params: ThreadRollbackParams, } | { "method": "thread/list", id: RequestId, params: ThreadListParams, } | { "method": "thread/loaded/list", id: RequestId, params: ThreadLoadedListParams, } | { "method": "thread/read", id: RequestId, params: ThreadReadParams, } | { "method": "skills/list", id: RequestId, params: SkillsListParams, } | { "method": "skills/remote/list", id: RequestId, params: SkillsRemoteReadParams, } | { "method": "skills/remote/export", id: RequestId, params: SkillsRemoteWriteParams, } | { "method": "app/list", id: RequestId, params: AppsListParams, } | { "method": "skills/config/write", id: RequestId, params: SkillsConfigWriteParams, } | { "method": "turn/start", id: RequestId, params: TurnStartParams, } | { "method": "turn/steer", id: RequestId, params: TurnSteerParams, } | { "method": "turn/interrupt", id: RequestId, params: TurnInterruptParams, } | { "method": "review/start", id: RequestId, params: ReviewStartParams, } | { "method": "model/list", id: RequestId, params: ModelListParams, } | { "method": "experimentalFeature/list", id: RequestId, params: ExperimentalFeatureListParams, } | { "method": "mcpServer/oauth/login", id: RequestId, params: McpServerOauthLoginParams, } | { "method": "config/mcpServer/reload", id: RequestId, params: undefined, } | { "method": "mcpServerStatus/list", id: RequestId, params: ListMcpServerStatusParams, } | { "method": "windowsSandbox/setupStart", id: RequestId, params: WindowsSandboxSetupStartParams, } | { "method": "account/login/start", id: RequestId, params: LoginAccountParams, } | { "method": "account/login/cancel", id: RequestId, params: CancelLoginAccountParams, } | { "method": "account/logout", id: RequestId, params: undefined, } | { "method": "account/rateLimits/read", id: RequestId, params: undefined, } | { "method": "feedback/upload", id: RequestId, params: FeedbackUploadParams, } | { "method": "command/exec", id: RequestId, params: CommandExecParams, } | { "method": "config/read", id: RequestId, params: ConfigReadParams, } | { "method": "externalAgentConfig/detect", id: RequestId, params: ExternalAgentConfigDetectParams, } | { "method": "externalAgentConfig/import", id: RequestId, params: ExternalAgentConfigImportParams, } | { "method": "config/value/write", id: RequestId, params: ConfigValueWriteParams, } | { "method": "config/batchWrite", id: RequestId, params: ConfigBatchWriteParams, } | { "method": "configRequirements/read", id: RequestId, params: undefined, } | { "method": "account/read", id: RequestId, params: GetAccountParams, } | { "method": "getConversationSummary", id: RequestId, params: GetConversationSummaryParams, } | { "method": "gitDiffToRemote", id: RequestId, params: GitDiffToRemoteParams, } | { "method": "getAuthStatus", id: RequestId, params: GetAuthStatusParams, } | { "method": "fuzzyFileSearch", id: RequestId, params: FuzzyFileSearchParams, };
export type ClientRequest ={ "method": "initialize", id: RequestId, params: InitializeParams, } | { "method": "thread/start", id: RequestId, params: ThreadStartParams, } | { "method": "thread/resume", id: RequestId, params: ThreadResumeParams, } | { "method": "thread/fork", id: RequestId, params: ThreadForkParams, } | { "method": "thread/archive", id: RequestId, params: ThreadArchiveParams, } | { "method": "thread/unsubscribe", id: RequestId, params: ThreadUnsubscribeParams, } | { "method": "thread/name/set", id: RequestId, params: ThreadSetNameParams, } | { "method": "thread/metadata/update", id: RequestId, params: ThreadMetadataUpdateParams, } | { "method": "thread/unarchive", id: RequestId, params: ThreadUnarchiveParams, } | { "method": "thread/compact/start", id: RequestId, params: ThreadCompactStartParams, } | { "method": "thread/rollback", id: RequestId, params: ThreadRollbackParams, } | { "method": "thread/list", id: RequestId, params: ThreadListParams, } | { "method": "thread/loaded/list", id: RequestId, params: ThreadLoadedListParams, } | { "method": "thread/read", id: RequestId, params: ThreadReadParams, } | { "method": "skills/list", id: RequestId, params: SkillsListParams, } | { "method": "skills/remote/list", id: RequestId, params: SkillsRemoteReadParams, } | { "method": "skills/remote/export", id: RequestId, params: SkillsRemoteWriteParams, } | { "method": "app/list", id: RequestId, params: AppsListParams, } | { "method": "skills/config/write", id: RequestId, params: SkillsConfigWriteParams, } | { "method": "plugin/install", id: RequestId, params: PluginInstallParams, } | { "method": "turn/start", id: RequestId, params: TurnStartParams, } | { "method": "turn/steer", id: RequestId, params: TurnSteerParams, } | { "method": "turn/interrupt", id: RequestId, params: TurnInterruptParams, } | { "method": "review/start", id: RequestId, params: ReviewStartParams, } | { "method": "model/list", id: RequestId, params: ModelListParams, } | { "method": "experimentalFeature/list", id: RequestId, params: ExperimentalFeatureListParams, } | { "method": "mcpServer/oauth/login", id: RequestId, params: McpServerOauthLoginParams, } | { "method": "config/mcpServer/reload", id: RequestId, params: undefined, } | { "method": "mcpServerStatus/list", id: RequestId, params: ListMcpServerStatusParams, } | { "method": "windowsSandbox/setupStart", id: RequestId, params: WindowsSandboxSetupStartParams, } | { "method": "account/login/start", id: RequestId, params: LoginAccountParams, } | { "method": "account/login/cancel", id: RequestId, params: CancelLoginAccountParams, } | { "method": "account/logout", id: RequestId, params: undefined, } | { "method": "account/rateLimits/read", id: RequestId, params: undefined, } | { "method": "feedback/upload", id: RequestId, params: FeedbackUploadParams, } | { "method": "command/exec", id: RequestId, params: CommandExecParams, } | { "method": "config/read", id: RequestId, params: ConfigReadParams, } | { "method": "externalAgentConfig/detect", id: RequestId, params: ExternalAgentConfigDetectParams, } | { "method": "externalAgentConfig/import", id: RequestId, params: ExternalAgentConfigImportParams, } | { "method": "config/value/write", id: RequestId, params: ConfigValueWriteParams, } | { "method": "config/batchWrite", id: RequestId, params: ConfigBatchWriteParams, } | { "method": "configRequirements/read", id: RequestId, params: undefined, } | { "method": "account/read", id: RequestId, params: GetAccountParams, } | { "method": "getConversationSummary", id: RequestId, params: GetConversationSummaryParams, } | { "method": "gitDiffToRemote", id: RequestId, params: GitDiffToRemoteParams, } | { "method": "getAuthStatus", id: RequestId, params: GetAuthStatusParams, } | { "method": "fuzzyFileSearch", id: RequestId, params: FuzzyFileSearchParams, };

View File

@@ -33,6 +33,8 @@ import type { ExecCommandEndEvent } from "./ExecCommandEndEvent";
import type { ExecCommandOutputDeltaEvent } from "./ExecCommandOutputDeltaEvent";
import type { ExitedReviewModeEvent } from "./ExitedReviewModeEvent";
import type { GetHistoryEntryResponseEvent } from "./GetHistoryEntryResponseEvent";
import type { ImageGenerationBeginEvent } from "./ImageGenerationBeginEvent";
import type { ImageGenerationEndEvent } from "./ImageGenerationEndEvent";
import type { ItemCompletedEvent } from "./ItemCompletedEvent";
import type { ItemStartedEvent } from "./ItemStartedEvent";
import type { ListCustomPromptsResponseEvent } from "./ListCustomPromptsResponseEvent";
@@ -79,4 +81,4 @@ import type { WebSearchEndEvent } from "./WebSearchEndEvent";
* Response event from the agent
* NOTE: Make sure none of these values have optional types, as it will mess up the extension code-gen.
*/
export type EventMsg = { "type": "error" } & ErrorEvent | { "type": "warning" } & WarningEvent | { "type": "realtime_conversation_started" } & RealtimeConversationStartedEvent | { "type": "realtime_conversation_realtime" } & RealtimeConversationRealtimeEvent | { "type": "realtime_conversation_closed" } & RealtimeConversationClosedEvent | { "type": "model_reroute" } & ModelRerouteEvent | { "type": "context_compacted" } & ContextCompactedEvent | { "type": "thread_rolled_back" } & ThreadRolledBackEvent | { "type": "task_started" } & TurnStartedEvent | { "type": "task_complete" } & TurnCompleteEvent | { "type": "token_count" } & TokenCountEvent | { "type": "agent_message" } & AgentMessageEvent | { "type": "user_message" } & UserMessageEvent | { "type": "agent_message_delta" } & AgentMessageDeltaEvent | { "type": "agent_reasoning" } & AgentReasoningEvent | { "type": "agent_reasoning_delta" } & AgentReasoningDeltaEvent | { "type": "agent_reasoning_raw_content" } & AgentReasoningRawContentEvent | { "type": "agent_reasoning_raw_content_delta" } & AgentReasoningRawContentDeltaEvent | { "type": "agent_reasoning_section_break" } & AgentReasoningSectionBreakEvent | { "type": "session_configured" } & SessionConfiguredEvent | { "type": "thread_name_updated" } & ThreadNameUpdatedEvent | { "type": "mcp_startup_update" } & McpStartupUpdateEvent | { "type": "mcp_startup_complete" } & McpStartupCompleteEvent | { "type": "mcp_tool_call_begin" } & McpToolCallBeginEvent | { "type": "mcp_tool_call_end" } & McpToolCallEndEvent | { "type": "web_search_begin" } & WebSearchBeginEvent | { "type": "web_search_end" } & WebSearchEndEvent | { "type": "exec_command_begin" } & ExecCommandBeginEvent | { "type": "exec_command_output_delta" } & ExecCommandOutputDeltaEvent | { "type": "terminal_interaction" } & TerminalInteractionEvent | { "type": "exec_command_end" } & ExecCommandEndEvent | { "type": "view_image_tool_call" } & ViewImageToolCallEvent | { "type": "exec_approval_request" } & ExecApprovalRequestEvent | { "type": "request_user_input" } & RequestUserInputEvent | { "type": "dynamic_tool_call_request" } & DynamicToolCallRequest | { "type": "dynamic_tool_call_response" } & DynamicToolCallResponseEvent | { "type": "elicitation_request" } & ElicitationRequestEvent | { "type": "apply_patch_approval_request" } & ApplyPatchApprovalRequestEvent | { "type": "deprecation_notice" } & DeprecationNoticeEvent | { "type": "background_event" } & BackgroundEventEvent | { "type": "undo_started" } & UndoStartedEvent | { "type": "undo_completed" } & UndoCompletedEvent | { "type": "stream_error" } & StreamErrorEvent | { "type": "patch_apply_begin" } & PatchApplyBeginEvent | { "type": "patch_apply_end" } & PatchApplyEndEvent | { "type": "turn_diff" } & TurnDiffEvent | { "type": "get_history_entry_response" } & GetHistoryEntryResponseEvent | { "type": "mcp_list_tools_response" } & McpListToolsResponseEvent | { "type": "list_custom_prompts_response" } & ListCustomPromptsResponseEvent | { "type": "list_skills_response" } & ListSkillsResponseEvent | { "type": "list_remote_skills_response" } & ListRemoteSkillsResponseEvent | { "type": "remote_skill_downloaded" } & RemoteSkillDownloadedEvent | { "type": "skills_update_available" } | { "type": "plan_update" } & UpdatePlanArgs | { "type": "turn_aborted" } & TurnAbortedEvent | { "type": "shutdown_complete" } | { "type": "entered_review_mode" } & ReviewRequest | { "type": "exited_review_mode" } & ExitedReviewModeEvent | { "type": "raw_response_item" } & RawResponseItemEvent | { "type": "item_started" } & ItemStartedEvent | { "type": "item_completed" } & ItemCompletedEvent | { "type": "agent_message_content_delta" } & AgentMessageContentDeltaEvent | { "type": "plan_delta" } & PlanDeltaEvent | { "type": "reasoning_content_delta" } & ReasoningContentDeltaEvent | { "type": "reasoning_raw_content_delta" } & ReasoningRawContentDeltaEvent | { "type": "collab_agent_spawn_begin" } & CollabAgentSpawnBeginEvent | { "type": "collab_agent_spawn_end" } & CollabAgentSpawnEndEvent | { "type": "collab_agent_interaction_begin" } & CollabAgentInteractionBeginEvent | { "type": "collab_agent_interaction_end" } & CollabAgentInteractionEndEvent | { "type": "collab_waiting_begin" } & CollabWaitingBeginEvent | { "type": "collab_waiting_end" } & CollabWaitingEndEvent | { "type": "collab_close_begin" } & CollabCloseBeginEvent | { "type": "collab_close_end" } & CollabCloseEndEvent | { "type": "collab_resume_begin" } & CollabResumeBeginEvent | { "type": "collab_resume_end" } & CollabResumeEndEvent;
export type EventMsg = { "type": "error" } & ErrorEvent | { "type": "warning" } & WarningEvent | { "type": "realtime_conversation_started" } & RealtimeConversationStartedEvent | { "type": "realtime_conversation_realtime" } & RealtimeConversationRealtimeEvent | { "type": "realtime_conversation_closed" } & RealtimeConversationClosedEvent | { "type": "model_reroute" } & ModelRerouteEvent | { "type": "context_compacted" } & ContextCompactedEvent | { "type": "thread_rolled_back" } & ThreadRolledBackEvent | { "type": "task_started" } & TurnStartedEvent | { "type": "task_complete" } & TurnCompleteEvent | { "type": "token_count" } & TokenCountEvent | { "type": "agent_message" } & AgentMessageEvent | { "type": "user_message" } & UserMessageEvent | { "type": "agent_message_delta" } & AgentMessageDeltaEvent | { "type": "agent_reasoning" } & AgentReasoningEvent | { "type": "agent_reasoning_delta" } & AgentReasoningDeltaEvent | { "type": "agent_reasoning_raw_content" } & AgentReasoningRawContentEvent | { "type": "agent_reasoning_raw_content_delta" } & AgentReasoningRawContentDeltaEvent | { "type": "agent_reasoning_section_break" } & AgentReasoningSectionBreakEvent | { "type": "session_configured" } & SessionConfiguredEvent | { "type": "thread_name_updated" } & ThreadNameUpdatedEvent | { "type": "mcp_startup_update" } & McpStartupUpdateEvent | { "type": "mcp_startup_complete" } & McpStartupCompleteEvent | { "type": "mcp_tool_call_begin" } & McpToolCallBeginEvent | { "type": "mcp_tool_call_end" } & McpToolCallEndEvent | { "type": "web_search_begin" } & WebSearchBeginEvent | { "type": "web_search_end" } & WebSearchEndEvent | { "type": "image_generation_begin" } & ImageGenerationBeginEvent | { "type": "image_generation_end" } & ImageGenerationEndEvent | { "type": "exec_command_begin" } & ExecCommandBeginEvent | { "type": "exec_command_output_delta" } & ExecCommandOutputDeltaEvent | { "type": "terminal_interaction" } & TerminalInteractionEvent | { "type": "exec_command_end" } & ExecCommandEndEvent | { "type": "view_image_tool_call" } & ViewImageToolCallEvent | { "type": "exec_approval_request" } & ExecApprovalRequestEvent | { "type": "request_user_input" } & RequestUserInputEvent | { "type": "dynamic_tool_call_request" } & DynamicToolCallRequest | { "type": "dynamic_tool_call_response" } & DynamicToolCallResponseEvent | { "type": "elicitation_request" } & ElicitationRequestEvent | { "type": "apply_patch_approval_request" } & ApplyPatchApprovalRequestEvent | { "type": "deprecation_notice" } & DeprecationNoticeEvent | { "type": "background_event" } & BackgroundEventEvent | { "type": "undo_started" } & UndoStartedEvent | { "type": "undo_completed" } & UndoCompletedEvent | { "type": "stream_error" } & StreamErrorEvent | { "type": "patch_apply_begin" } & PatchApplyBeginEvent | { "type": "patch_apply_end" } & PatchApplyEndEvent | { "type": "turn_diff" } & TurnDiffEvent | { "type": "get_history_entry_response" } & GetHistoryEntryResponseEvent | { "type": "mcp_list_tools_response" } & McpListToolsResponseEvent | { "type": "list_custom_prompts_response" } & ListCustomPromptsResponseEvent | { "type": "list_skills_response" } & ListSkillsResponseEvent | { "type": "list_remote_skills_response" } & ListRemoteSkillsResponseEvent | { "type": "remote_skill_downloaded" } & RemoteSkillDownloadedEvent | { "type": "skills_update_available" } | { "type": "plan_update" } & UpdatePlanArgs | { "type": "turn_aborted" } & TurnAbortedEvent | { "type": "shutdown_complete" } | { "type": "entered_review_mode" } & ReviewRequest | { "type": "exited_review_mode" } & ExitedReviewModeEvent | { "type": "raw_response_item" } & RawResponseItemEvent | { "type": "item_started" } & ItemStartedEvent | { "type": "item_completed" } & ItemCompletedEvent | { "type": "agent_message_content_delta" } & AgentMessageContentDeltaEvent | { "type": "plan_delta" } & PlanDeltaEvent | { "type": "reasoning_content_delta" } & ReasoningContentDeltaEvent | { "type": "reasoning_raw_content_delta" } & ReasoningRawContentDeltaEvent | { "type": "collab_agent_spawn_begin" } & CollabAgentSpawnBeginEvent | { "type": "collab_agent_spawn_end" } & CollabAgentSpawnEndEvent | { "type": "collab_agent_interaction_begin" } & CollabAgentInteractionBeginEvent | { "type": "collab_agent_interaction_end" } & CollabAgentInteractionEndEvent | { "type": "collab_waiting_begin" } & CollabWaitingBeginEvent | { "type": "collab_waiting_end" } & CollabWaitingEndEvent | { "type": "collab_close_begin" } & CollabCloseBeginEvent | { "type": "collab_close_end" } & CollabCloseEndEvent | { "type": "collab_resume_begin" } & CollabResumeBeginEvent | { "type": "collab_resume_end" } & CollabResumeEndEvent;

View File

@@ -0,0 +1,5 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type ImageGenerationBeginEvent = { call_id: string, };

View File

@@ -0,0 +1,5 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type ImageGenerationEndEvent = { call_id: string, status: string, revised_prompt?: string, result: string, };

View File

@@ -0,0 +1,5 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type ImageGenerationItem = { id: string, status: string, revised_prompt?: string, result: string, };

View File

@@ -0,0 +1,5 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type NetworkPermissions = { enabled: boolean | null, };

View File

@@ -3,5 +3,6 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { FileSystemPermissions } from "./FileSystemPermissions";
import type { MacOsPermissions } from "./MacOsPermissions";
import type { NetworkPermissions } from "./NetworkPermissions";
export type PermissionProfile = { network: boolean | null, file_system: FileSystemPermissions | null, macos: MacOsPermissions | null, };
export type PermissionProfile = { network: NetworkPermissions | null, file_system: FileSystemPermissions | null, macos: MacOsPermissions | null, };

View File

@@ -15,4 +15,4 @@ export type ResponseItem = { "type": "message", role: string, content: Array<Con
/**
* Set when using the Responses API.
*/
call_id: string | null, status: LocalShellStatus, action: LocalShellAction, } | { "type": "function_call", name: string, arguments: string, call_id: string, } | { "type": "function_call_output", call_id: string, output: FunctionCallOutputPayload, } | { "type": "custom_tool_call", status?: string, call_id: string, name: string, input: string, } | { "type": "custom_tool_call_output", call_id: string, output: FunctionCallOutputPayload, } | { "type": "web_search_call", status?: string, action?: WebSearchAction, } | { "type": "ghost_snapshot", ghost_commit: GhostCommit, } | { "type": "compaction", encrypted_content: string, } | { "type": "other" };
call_id: string | null, status: LocalShellStatus, action: LocalShellAction, } | { "type": "function_call", name: string, arguments: string, call_id: string, } | { "type": "function_call_output", call_id: string, output: FunctionCallOutputPayload, } | { "type": "custom_tool_call", status?: string, call_id: string, name: string, input: string, } | { "type": "custom_tool_call_output", call_id: string, output: FunctionCallOutputPayload, } | { "type": "web_search_call", status?: string, action?: WebSearchAction, } | { "type": "image_generation_call", id: string, status: string, revised_prompt?: string, result: string, } | { "type": "ghost_snapshot", ghost_commit: GhostCommit, } | { "type": "compaction", encrypted_content: string, } | { "type": "other" };

View File

@@ -2,4 +2,4 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type ServiceTier = "fast";
export type ServiceTier = "fast" | "flex";

View File

@@ -3,9 +3,10 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { AgentMessageItem } from "./AgentMessageItem";
import type { ContextCompactionItem } from "./ContextCompactionItem";
import type { ImageGenerationItem } from "./ImageGenerationItem";
import type { PlanItem } from "./PlanItem";
import type { ReasoningItem } from "./ReasoningItem";
import type { UserMessageItem } from "./UserMessageItem";
import type { WebSearchItem } from "./WebSearchItem";
export type TurnItem = { "type": "UserMessage" } & UserMessageItem | { "type": "AgentMessage" } & AgentMessageItem | { "type": "Plan" } & PlanItem | { "type": "Reasoning" } & ReasoningItem | { "type": "WebSearch" } & WebSearchItem | { "type": "ContextCompaction" } & ContextCompactionItem;
export type TurnItem = { "type": "UserMessage" } & UserMessageItem | { "type": "AgentMessage" } & AgentMessageItem | { "type": "Plan" } & PlanItem | { "type": "Reasoning" } & ReasoningItem | { "type": "WebSearch" } & WebSearchItem | { "type": "ImageGeneration" } & ImageGenerationItem | { "type": "ContextCompaction" } & ContextCompactionItem;

View File

@@ -84,6 +84,9 @@ export type { GitDiffToRemoteResponse } from "./GitDiffToRemoteResponse";
export type { GitSha } from "./GitSha";
export type { HistoryEntry } from "./HistoryEntry";
export type { ImageDetail } from "./ImageDetail";
export type { ImageGenerationBeginEvent } from "./ImageGenerationBeginEvent";
export type { ImageGenerationEndEvent } from "./ImageGenerationEndEvent";
export type { ImageGenerationItem } from "./ImageGenerationItem";
export type { InitializeCapabilities } from "./InitializeCapabilities";
export type { InitializeParams } from "./InitializeParams";
export type { InitializeResponse } from "./InitializeResponse";
@@ -115,6 +118,7 @@ export type { ModelRerouteReason } from "./ModelRerouteReason";
export type { NetworkAccess } from "./NetworkAccess";
export type { NetworkApprovalContext } from "./NetworkApprovalContext";
export type { NetworkApprovalProtocol } from "./NetworkApprovalProtocol";
export type { NetworkPermissions } from "./NetworkPermissions";
export type { NetworkPolicyAmendment } from "./NetworkPolicyAmendment";
export type { NetworkPolicyRuleAction } from "./NetworkPolicyRuleAction";
export type { ParsedCommand } from "./ParsedCommand";

View File

@@ -0,0 +1,5 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type AdditionalNetworkPermissions = { enabled: boolean | null, };

View File

@@ -3,5 +3,6 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { AdditionalFileSystemPermissions } from "./AdditionalFileSystemPermissions";
import type { AdditionalMacOsPermissions } from "./AdditionalMacOsPermissions";
import type { AdditionalNetworkPermissions } from "./AdditionalNetworkPermissions";
export type AdditionalPermissionProfile = { network: boolean | null, fileSystem: AdditionalFileSystemPermissions | null, macos: AdditionalMacOsPermissions | null, };
export type AdditionalPermissionProfile = { network: AdditionalNetworkPermissions | null, fileSystem: AdditionalFileSystemPermissions | null, macos: AdditionalMacOsPermissions | null, };

View File

@@ -0,0 +1,5 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type PluginInstallParams = { marketplaceName: string, pluginName: string, cwd?: string | null, };

View File

@@ -0,0 +1,5 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type PluginInstallResponse = Record<string, never>;

View File

@@ -85,4 +85,4 @@ prompt: string | null,
/**
* Last known status of the target agents, when available.
*/
agentsStates: { [key in string]?: CollabAgentState }, } | { "type": "webSearch", id: string, query: string, action: WebSearchAction | null, } | { "type": "imageView", id: string, path: string, } | { "type": "enteredReviewMode", id: string, review: string, } | { "type": "exitedReviewMode", id: string, review: string, } | { "type": "contextCompaction", id: string, };
agentsStates: { [key in string]?: CollabAgentState }, } | { "type": "webSearch", id: string, query: string, action: WebSearchAction | null, } | { "type": "imageView", id: string, path: string, } | { "type": "imageGeneration", id: string, status: string, revisedPrompt: string | null, result: string, } | { "type": "enteredReviewMode", id: string, review: string, } | { "type": "exitedReviewMode", id: string, review: string, } | { "type": "contextCompaction", id: string, };

View File

@@ -3,4 +3,4 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { WindowsSandboxSetupMode } from "./WindowsSandboxSetupMode";
export type WindowsSandboxSetupStartParams = { mode: WindowsSandboxSetupMode, };
export type WindowsSandboxSetupStartParams = { mode: WindowsSandboxSetupMode, cwd?: string | null, };

View File

@@ -6,6 +6,7 @@ export type { AccountRateLimitsUpdatedNotification } from "./AccountRateLimitsUp
export type { AccountUpdatedNotification } from "./AccountUpdatedNotification";
export type { AdditionalFileSystemPermissions } from "./AdditionalFileSystemPermissions";
export type { AdditionalMacOsPermissions } from "./AdditionalMacOsPermissions";
export type { AdditionalNetworkPermissions } from "./AdditionalNetworkPermissions";
export type { AdditionalPermissionProfile } from "./AdditionalPermissionProfile";
export type { AgentMessageDeltaNotification } from "./AgentMessageDeltaNotification";
export type { AnalyticsConfig } from "./AnalyticsConfig";
@@ -123,6 +124,8 @@ export type { OverriddenMetadata } from "./OverriddenMetadata";
export type { PatchApplyStatus } from "./PatchApplyStatus";
export type { PatchChangeKind } from "./PatchChangeKind";
export type { PlanDeltaNotification } from "./PlanDeltaNotification";
export type { PluginInstallParams } from "./PluginInstallParams";
export type { PluginInstallResponse } from "./PluginInstallResponse";
export type { ProductSurface } from "./ProductSurface";
export type { ProfileV2 } from "./ProfileV2";
export type { RateLimitSnapshot } from "./RateLimitSnapshot";

View File

@@ -264,6 +264,10 @@ client_request_definitions! {
params: v2::SkillsConfigWriteParams,
response: v2::SkillsConfigWriteResponse,
},
PluginInstall => "plugin/install" {
params: v2::PluginInstallParams,
response: v2::PluginInstallResponse,
},
TurnStart => "turn/start" {
params: v2::TurnStartParams,
inspect_params: true,

View File

@@ -30,6 +30,8 @@ use codex_protocol::protocol::ErrorEvent;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::ExecCommandBeginEvent;
use codex_protocol::protocol::ExecCommandEndEvent;
use codex_protocol::protocol::ImageGenerationBeginEvent;
use codex_protocol::protocol::ImageGenerationEndEvent;
use codex_protocol::protocol::ItemCompletedEvent;
use codex_protocol::protocol::ItemStartedEvent;
use codex_protocol::protocol::McpToolCallBeginEvent;
@@ -141,6 +143,8 @@ impl ThreadHistoryBuilder {
EventMsg::McpToolCallBegin(payload) => self.handle_mcp_tool_call_begin(payload),
EventMsg::McpToolCallEnd(payload) => self.handle_mcp_tool_call_end(payload),
EventMsg::ViewImageToolCall(payload) => self.handle_view_image_tool_call(payload),
EventMsg::ImageGenerationBegin(payload) => self.handle_image_generation_begin(payload),
EventMsg::ImageGenerationEnd(payload) => self.handle_image_generation_end(payload),
EventMsg::CollabAgentSpawnBegin(payload) => {
self.handle_collab_agent_spawn_begin(payload)
}
@@ -269,6 +273,7 @@ impl ThreadHistoryBuilder {
| codex_protocol::items::TurnItem::AgentMessage(_)
| codex_protocol::items::TurnItem::Reasoning(_)
| codex_protocol::items::TurnItem::WebSearch(_)
| codex_protocol::items::TurnItem::ImageGeneration(_)
| codex_protocol::items::TurnItem::ContextCompaction(_) => {}
}
}
@@ -288,6 +293,7 @@ impl ThreadHistoryBuilder {
| codex_protocol::items::TurnItem::AgentMessage(_)
| codex_protocol::items::TurnItem::Reasoning(_)
| codex_protocol::items::TurnItem::WebSearch(_)
| codex_protocol::items::TurnItem::ImageGeneration(_)
| codex_protocol::items::TurnItem::ContextCompaction(_) => {}
}
}
@@ -516,6 +522,26 @@ impl ThreadHistoryBuilder {
self.upsert_item_in_current_turn(item);
}
fn handle_image_generation_begin(&mut self, payload: &ImageGenerationBeginEvent) {
let item = ThreadItem::ImageGeneration {
id: payload.call_id.clone(),
status: String::new(),
revised_prompt: None,
result: String::new(),
};
self.upsert_item_in_current_turn(item);
}
fn handle_image_generation_end(&mut self, payload: &ImageGenerationEndEvent) {
let item = ThreadItem::ImageGeneration {
id: payload.call_id.clone(),
status: payload.status.clone(),
revised_prompt: payload.revised_prompt.clone(),
result: payload.result.clone(),
};
self.upsert_item_in_current_turn(item);
}
fn handle_collab_agent_spawn_begin(
&mut self,
payload: &codex_protocol::protocol::CollabAgentSpawnBeginEvent,

View File

@@ -31,6 +31,7 @@ use codex_protocol::models::MacOsAutomationValue as CoreMacOsAutomationValue;
use codex_protocol::models::MacOsPermissions as CoreMacOsPermissions;
use codex_protocol::models::MacOsPreferencesValue as CoreMacOsPreferencesValue;
use codex_protocol::models::MessagePhase;
use codex_protocol::models::NetworkPermissions as CoreNetworkPermissions;
use codex_protocol::models::PermissionProfile as CorePermissionProfile;
use codex_protocol::models::ResponseItem;
use codex_protocol::openai_models::InputModality;
@@ -852,11 +853,26 @@ impl From<CoreMacOsPermissions> for AdditionalMacOsPermissions {
}
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct AdditionalNetworkPermissions {
pub enabled: Option<bool>,
}
impl From<CoreNetworkPermissions> for AdditionalNetworkPermissions {
fn from(value: CoreNetworkPermissions) -> Self {
Self {
enabled: value.enabled,
}
}
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct AdditionalPermissionProfile {
pub network: Option<bool>,
pub network: Option<AdditionalNetworkPermissions>,
pub file_system: Option<AdditionalFileSystemPermissions>,
pub macos: Option<AdditionalMacOsPermissions>,
}
@@ -864,7 +880,7 @@ pub struct AdditionalPermissionProfile {
impl From<CorePermissionProfile> for AdditionalPermissionProfile {
fn from(value: CorePermissionProfile) -> Self {
Self {
network: value.network,
network: value.network.map(AdditionalNetworkPermissions::from),
file_system: value.file_system.map(AdditionalFileSystemPermissions::from),
macos: value.macos.map(AdditionalMacOsPermissions::from),
}
@@ -2530,6 +2546,21 @@ pub struct SkillsConfigWriteResponse {
pub effective_enabled: bool,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct PluginInstallParams {
pub marketplace_name: String,
pub plugin_name: String,
#[ts(optional = nullable)]
pub cwd: Option<PathBuf>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct PluginInstallResponse {}
impl From<CoreSkillMetadata> for SkillMetadata {
fn from(value: CoreSkillMetadata) -> Self {
Self {
@@ -3316,6 +3347,14 @@ pub enum ThreadItem {
ImageView { id: String, path: String },
#[serde(rename_all = "camelCase")]
#[ts(rename_all = "camelCase")]
ImageGeneration {
id: String,
status: String,
revised_prompt: Option<String>,
result: String,
},
#[serde(rename_all = "camelCase")]
#[ts(rename_all = "camelCase")]
EnteredReviewMode { id: String, review: String },
#[serde(rename_all = "camelCase")]
#[ts(rename_all = "camelCase")]
@@ -3339,6 +3378,7 @@ impl ThreadItem {
| ThreadItem::CollabAgentToolCall { id, .. }
| ThreadItem::WebSearch { id, .. }
| ThreadItem::ImageView { id, .. }
| ThreadItem::ImageGeneration { id, .. }
| ThreadItem::EnteredReviewMode { id, .. }
| ThreadItem::ExitedReviewMode { id, .. }
| ThreadItem::ContextCompaction { id, .. } => id,
@@ -3418,6 +3458,12 @@ impl From<CoreTurnItem> for ThreadItem {
query: search.query,
action: Some(WebSearchAction::from(search.action)),
},
CoreTurnItem::ImageGeneration(image) => ThreadItem::ImageGeneration {
id: image.id,
status: image.status,
revised_prompt: image.revised_prompt,
result: image.result,
},
CoreTurnItem::ContextCompaction(compaction) => {
ThreadItem::ContextCompaction { id: compaction.id }
}
@@ -3911,6 +3957,8 @@ pub enum WindowsSandboxSetupMode {
#[ts(export_to = "v2/")]
pub struct WindowsSandboxSetupStartParams {
pub mode: WindowsSandboxSetupMode,
#[ts(optional = nullable)]
pub cwd: Option<PathBuf>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]

View File

@@ -11,9 +11,15 @@ workspace = true
anyhow = { workspace = true }
clap = { workspace = true, features = ["derive", "env"] }
codex-app-server-protocol = { workspace = true }
codex-core = { workspace = true }
codex-otel = { workspace = true }
codex-protocol = { workspace = true }
codex-utils-cli = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
tokio = { workspace = true, features = ["rt"] }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
tungstenite = { workspace = true }
url = { workspace = true }
uuid = { workspace = true, features = ["v4"] }

View File

@@ -62,10 +62,17 @@ use codex_app_server_protocol::TurnStartParams;
use codex_app_server_protocol::TurnStartResponse;
use codex_app_server_protocol::TurnStatus;
use codex_app_server_protocol::UserInput as V2UserInput;
use codex_core::config::Config;
use codex_otel::current_span_w3c_trace_context;
use codex_otel::otel_provider::OtelProvider;
use codex_protocol::protocol::W3cTraceContext;
use codex_utils_cli::CliConfigOverrides;
use serde::Serialize;
use serde::de::DeserializeOwned;
use serde_json::Value;
use tracing::info_span;
use tracing_subscriber::layer::SubscriberExt;
use tracing_subscriber::util::SubscriberInitExt;
use tungstenite::Message;
use tungstenite::WebSocket;
use tungstenite::connect;
@@ -98,6 +105,10 @@ const NOTIFICATIONS_TO_OPT_OUT: &[&str] = &[
];
const APP_SERVER_GRACEFUL_SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(5);
const APP_SERVER_GRACEFUL_SHUTDOWN_POLL_INTERVAL: Duration = Duration::from_millis(100);
const DEFAULT_ANALYTICS_ENABLED: bool = true;
const OTEL_SERVICE_NAME: &str = "codex-app-server-test-client";
const TRACE_DISABLED_MESSAGE: &str =
"Not enabled - enable tracing in $CODEX_HOME/config.toml to get a trace URL!";
/// Minimal launcher that initializes the Codex app-server and logs the handshake.
#[derive(Parser)]
@@ -236,7 +247,7 @@ enum CliCommand {
},
}
pub fn run() -> Result<()> {
pub async fn run() -> Result<()> {
let Cli {
codex_bin,
url,
@@ -256,7 +267,7 @@ pub fn run() -> Result<()> {
CliCommand::SendMessage { user_message } => {
ensure_dynamic_tools_unused(&dynamic_tools, "send-message")?;
let endpoint = resolve_endpoint(codex_bin, url)?;
send_message(&endpoint, &config_overrides, user_message)
send_message(&endpoint, &config_overrides, user_message).await
}
CliCommand::SendMessageV2 {
experimental_api,
@@ -270,6 +281,7 @@ pub fn run() -> Result<()> {
experimental_api,
&dynamic_tools,
)
.await
}
CliCommand::ResumeMessageV2 {
thread_id,
@@ -283,28 +295,29 @@ pub fn run() -> Result<()> {
user_message,
&dynamic_tools,
)
.await
}
CliCommand::ThreadResume { thread_id } => {
ensure_dynamic_tools_unused(&dynamic_tools, "thread-resume")?;
let endpoint = resolve_endpoint(codex_bin, url)?;
thread_resume_follow(&endpoint, &config_overrides, thread_id)
thread_resume_follow(&endpoint, &config_overrides, thread_id).await
}
CliCommand::Watch => {
ensure_dynamic_tools_unused(&dynamic_tools, "watch")?;
let endpoint = resolve_endpoint(codex_bin, url)?;
watch(&endpoint, &config_overrides)
watch(&endpoint, &config_overrides).await
}
CliCommand::TriggerCmdApproval { user_message } => {
let endpoint = resolve_endpoint(codex_bin, url)?;
trigger_cmd_approval(&endpoint, &config_overrides, user_message, &dynamic_tools)
trigger_cmd_approval(&endpoint, &config_overrides, user_message, &dynamic_tools).await
}
CliCommand::TriggerPatchApproval { user_message } => {
let endpoint = resolve_endpoint(codex_bin, url)?;
trigger_patch_approval(&endpoint, &config_overrides, user_message, &dynamic_tools)
trigger_patch_approval(&endpoint, &config_overrides, user_message, &dynamic_tools).await
}
CliCommand::NoTriggerCmdApproval => {
let endpoint = resolve_endpoint(codex_bin, url)?;
no_trigger_cmd_approval(&endpoint, &config_overrides, &dynamic_tools)
no_trigger_cmd_approval(&endpoint, &config_overrides, &dynamic_tools).await
}
CliCommand::SendFollowUpV2 {
first_message,
@@ -318,6 +331,7 @@ pub fn run() -> Result<()> {
follow_up_message,
&dynamic_tools,
)
.await
}
CliCommand::TriggerZshForkMultiCmdApproval {
user_message,
@@ -333,26 +347,27 @@ pub fn run() -> Result<()> {
abort_on,
&dynamic_tools,
)
.await
}
CliCommand::TestLogin => {
ensure_dynamic_tools_unused(&dynamic_tools, "test-login")?;
let endpoint = resolve_endpoint(codex_bin, url)?;
test_login(&endpoint, &config_overrides)
test_login(&endpoint, &config_overrides).await
}
CliCommand::GetAccountRateLimits => {
ensure_dynamic_tools_unused(&dynamic_tools, "get-account-rate-limits")?;
let endpoint = resolve_endpoint(codex_bin, url)?;
get_account_rate_limits(&endpoint, &config_overrides)
get_account_rate_limits(&endpoint, &config_overrides).await
}
CliCommand::ModelList => {
ensure_dynamic_tools_unused(&dynamic_tools, "model-list")?;
let endpoint = resolve_endpoint(codex_bin, url)?;
model_list(&endpoint, &config_overrides)
model_list(&endpoint, &config_overrides).await
}
CliCommand::ThreadList { limit } => {
ensure_dynamic_tools_unused(&dynamic_tools, "thread-list")?;
let endpoint = resolve_endpoint(codex_bin, url)?;
thread_list(&endpoint, &config_overrides, limit)
thread_list(&endpoint, &config_overrides, limit).await
}
}
}
@@ -487,7 +502,15 @@ fn shell_quote(input: &str) -> String {
format!("'{}'", input.replace('\'', "'\\''"))
}
fn send_message(
struct SendMessagePolicies<'a> {
command_name: &'static str,
experimental_api: bool,
approval_policy: Option<AskForApproval>,
sandbox_policy: Option<SandboxPolicy>,
dynamic_tools: &'a Option<Vec<DynamicToolSpec>>,
}
async fn send_message(
endpoint: &Endpoint,
config_overrides: &[String],
user_message: String,
@@ -497,14 +520,18 @@ fn send_message(
endpoint,
config_overrides,
user_message,
false,
None,
None,
&dynamic_tools,
SendMessagePolicies {
command_name: "send-message",
experimental_api: false,
approval_policy: None,
sandbox_policy: None,
dynamic_tools: &dynamic_tools,
},
)
.await
}
pub fn send_message_v2(
pub async fn send_message_v2(
codex_bin: &Path,
config_overrides: &[String],
user_message: String,
@@ -518,9 +545,10 @@ pub fn send_message_v2(
true,
dynamic_tools,
)
.await
}
fn send_message_v2_endpoint(
async fn send_message_v2_endpoint(
endpoint: &Endpoint,
config_overrides: &[String],
user_message: String,
@@ -535,14 +563,18 @@ fn send_message_v2_endpoint(
endpoint,
config_overrides,
user_message,
experimental_api,
None,
None,
dynamic_tools,
SendMessagePolicies {
command_name: "send-message-v2",
experimental_api,
approval_policy: None,
sandbox_policy: None,
dynamic_tools,
},
)
.await
}
fn trigger_zsh_fork_multi_cmd_approval(
async fn trigger_zsh_fork_multi_cmd_approval(
endpoint: &Endpoint,
config_overrides: &[String],
user_message: Option<String>,
@@ -559,89 +591,96 @@ fn trigger_zsh_fork_multi_cmd_approval(
let default_prompt = "Run this exact command using shell command execution without rewriting or splitting it: /usr/bin/true && /usr/bin/true";
let message = user_message.unwrap_or_else(|| default_prompt.to_string());
with_client(endpoint, config_overrides, |client| {
let initialize = client.initialize()?;
println!("< initialize response: {initialize:?}");
with_client(
"trigger-zsh-fork-multi-cmd-approval",
endpoint,
config_overrides,
|client| {
let initialize = client.initialize()?;
println!("< initialize response: {initialize:?}");
let thread_response = client.thread_start(ThreadStartParams {
dynamic_tools: dynamic_tools.clone(),
..Default::default()
})?;
println!("< thread/start response: {thread_response:?}");
let thread_response = client.thread_start(ThreadStartParams {
dynamic_tools: dynamic_tools.clone(),
..Default::default()
})?;
println!("< thread/start response: {thread_response:?}");
client.command_approval_behavior = match abort_on {
Some(index) => CommandApprovalBehavior::AbortOn(index),
None => CommandApprovalBehavior::AlwaysAccept,
};
client.command_approval_count = 0;
client.command_approval_item_ids.clear();
client.command_execution_statuses.clear();
client.last_turn_status = None;
client.command_approval_behavior = match abort_on {
Some(index) => CommandApprovalBehavior::AbortOn(index),
None => CommandApprovalBehavior::AlwaysAccept,
};
client.command_approval_count = 0;
client.command_approval_item_ids.clear();
client.command_execution_statuses.clear();
client.last_turn_status = None;
let mut turn_params = TurnStartParams {
thread_id: thread_response.thread.id.clone(),
input: vec![V2UserInput::Text {
text: message,
text_elements: Vec::new(),
}],
..Default::default()
};
turn_params.approval_policy = Some(AskForApproval::OnRequest);
turn_params.sandbox_policy = Some(SandboxPolicy::ReadOnly {
access: ReadOnlyAccess::FullAccess,
network_access: false,
});
let mut turn_params = TurnStartParams {
thread_id: thread_response.thread.id.clone(),
input: vec![V2UserInput::Text {
text: message,
text_elements: Vec::new(),
}],
..Default::default()
};
turn_params.approval_policy = Some(AskForApproval::OnRequest);
turn_params.sandbox_policy = Some(SandboxPolicy::ReadOnly {
access: ReadOnlyAccess::FullAccess,
network_access: false,
});
let turn_response = client.turn_start(turn_params)?;
println!("< turn/start response: {turn_response:?}");
client.stream_turn(&thread_response.thread.id, &turn_response.turn.id)?;
let turn_response = client.turn_start(turn_params)?;
println!("< turn/start response: {turn_response:?}");
client.stream_turn(&thread_response.thread.id, &turn_response.turn.id)?;
if client.command_approval_count < min_approvals {
bail!(
"expected at least {min_approvals} command approvals, got {}",
client.command_approval_count
);
}
let mut approvals_per_item = std::collections::BTreeMap::new();
for item_id in &client.command_approval_item_ids {
*approvals_per_item.entry(item_id.clone()).or_insert(0usize) += 1;
}
let max_approvals_for_one_item = approvals_per_item.values().copied().max().unwrap_or(0);
if max_approvals_for_one_item < min_approvals {
bail!(
"expected at least {min_approvals} approvals for one command item, got max {max_approvals_for_one_item} with map {approvals_per_item:?}"
);
}
let last_command_status = client.command_execution_statuses.last();
if abort_on.is_none() {
if last_command_status != Some(&CommandExecutionStatus::Completed) {
bail!("expected completed command execution, got {last_command_status:?}");
}
if client.last_turn_status != Some(TurnStatus::Completed) {
if client.command_approval_count < min_approvals {
bail!(
"expected completed turn in all-accept flow, got {:?}",
client.last_turn_status
"expected at least {min_approvals} command approvals, got {}",
client.command_approval_count
);
}
} else if last_command_status == Some(&CommandExecutionStatus::Completed) {
bail!(
"expected non-completed command execution in mixed approval/decline flow, got {last_command_status:?}"
let mut approvals_per_item = std::collections::BTreeMap::new();
for item_id in &client.command_approval_item_ids {
*approvals_per_item.entry(item_id.clone()).or_insert(0usize) += 1;
}
let max_approvals_for_one_item =
approvals_per_item.values().copied().max().unwrap_or(0);
if max_approvals_for_one_item < min_approvals {
bail!(
"expected at least {min_approvals} approvals for one command item, got max {max_approvals_for_one_item} with map {approvals_per_item:?}"
);
}
let last_command_status = client.command_execution_statuses.last();
if abort_on.is_none() {
if last_command_status != Some(&CommandExecutionStatus::Completed) {
bail!("expected completed command execution, got {last_command_status:?}");
}
if client.last_turn_status != Some(TurnStatus::Completed) {
bail!(
"expected completed turn in all-accept flow, got {:?}",
client.last_turn_status
);
}
} else if last_command_status == Some(&CommandExecutionStatus::Completed) {
bail!(
"expected non-completed command execution in mixed approval/decline flow, got {last_command_status:?}"
);
}
println!(
"[zsh-fork multi-approval summary] approvals={}, approvals_per_item={approvals_per_item:?}, command_statuses={:?}, turn_status={:?}",
client.command_approval_count,
client.command_execution_statuses,
client.last_turn_status
);
}
println!(
"[zsh-fork multi-approval summary] approvals={}, approvals_per_item={approvals_per_item:?}, command_statuses={:?}, turn_status={:?}",
client.command_approval_count,
client.command_execution_statuses,
client.last_turn_status
);
Ok(())
})
Ok(())
},
)
.await
}
fn resume_message_v2(
async fn resume_message_v2(
endpoint: &Endpoint,
config_overrides: &[String],
thread_id: String,
@@ -650,7 +689,7 @@ fn resume_message_v2(
) -> Result<()> {
ensure_dynamic_tools_unused(dynamic_tools, "resume-message-v2")?;
with_client(endpoint, config_overrides, |client| {
with_client("resume-message-v2", endpoint, config_overrides, |client| {
let initialize = client.initialize()?;
println!("< initialize response: {initialize:?}");
@@ -674,39 +713,42 @@ fn resume_message_v2(
Ok(())
})
.await
}
fn thread_resume_follow(
async fn thread_resume_follow(
endpoint: &Endpoint,
config_overrides: &[String],
thread_id: String,
) -> Result<()> {
let mut client = CodexClient::connect(endpoint, config_overrides)?;
with_client("thread-resume", endpoint, config_overrides, |client| {
let initialize = client.initialize()?;
println!("< initialize response: {initialize:?}");
let initialize = client.initialize()?;
println!("< initialize response: {initialize:?}");
let resume_response = client.thread_resume(ThreadResumeParams {
thread_id,
..Default::default()
})?;
println!("< thread/resume response: {resume_response:?}");
println!("< streaming notifications until process is terminated");
let resume_response = client.thread_resume(ThreadResumeParams {
thread_id,
..Default::default()
})?;
println!("< thread/resume response: {resume_response:?}");
println!("< streaming notifications until process is terminated");
client.stream_notifications_forever()
client.stream_notifications_forever()
})
.await
}
fn watch(endpoint: &Endpoint, config_overrides: &[String]) -> Result<()> {
let mut client = CodexClient::connect(endpoint, config_overrides)?;
async fn watch(endpoint: &Endpoint, config_overrides: &[String]) -> Result<()> {
with_client("watch", endpoint, config_overrides, |client| {
let initialize = client.initialize()?;
println!("< initialize response: {initialize:?}");
println!("< streaming inbound messages until process is terminated");
let initialize = client.initialize()?;
println!("< initialize response: {initialize:?}");
println!("< streaming inbound messages until process is terminated");
client.stream_notifications_forever()
client.stream_notifications_forever()
})
.await
}
fn trigger_cmd_approval(
async fn trigger_cmd_approval(
endpoint: &Endpoint,
config_overrides: &[String],
user_message: Option<String>,
@@ -719,17 +761,21 @@ fn trigger_cmd_approval(
endpoint,
config_overrides,
message,
true,
Some(AskForApproval::OnRequest),
Some(SandboxPolicy::ReadOnly {
access: ReadOnlyAccess::FullAccess,
network_access: false,
}),
dynamic_tools,
SendMessagePolicies {
command_name: "trigger-cmd-approval",
experimental_api: true,
approval_policy: Some(AskForApproval::OnRequest),
sandbox_policy: Some(SandboxPolicy::ReadOnly {
access: ReadOnlyAccess::FullAccess,
network_access: false,
}),
dynamic_tools,
},
)
.await
}
fn trigger_patch_approval(
async fn trigger_patch_approval(
endpoint: &Endpoint,
config_overrides: &[String],
user_message: Option<String>,
@@ -742,17 +788,21 @@ fn trigger_patch_approval(
endpoint,
config_overrides,
message,
true,
Some(AskForApproval::OnRequest),
Some(SandboxPolicy::ReadOnly {
access: ReadOnlyAccess::FullAccess,
network_access: false,
}),
dynamic_tools,
SendMessagePolicies {
command_name: "trigger-patch-approval",
experimental_api: true,
approval_policy: Some(AskForApproval::OnRequest),
sandbox_policy: Some(SandboxPolicy::ReadOnly {
access: ReadOnlyAccess::FullAccess,
network_access: false,
}),
dynamic_tools,
},
)
.await
}
fn no_trigger_cmd_approval(
async fn no_trigger_cmd_approval(
endpoint: &Endpoint,
config_overrides: &[String],
dynamic_tools: &Option<Vec<DynamicToolSpec>>,
@@ -762,60 +812,67 @@ fn no_trigger_cmd_approval(
endpoint,
config_overrides,
prompt.to_string(),
true,
None,
None,
dynamic_tools,
SendMessagePolicies {
command_name: "no-trigger-cmd-approval",
experimental_api: true,
approval_policy: None,
sandbox_policy: None,
dynamic_tools,
},
)
.await
}
fn send_message_v2_with_policies(
async fn send_message_v2_with_policies(
endpoint: &Endpoint,
config_overrides: &[String],
user_message: String,
experimental_api: bool,
approval_policy: Option<AskForApproval>,
sandbox_policy: Option<SandboxPolicy>,
dynamic_tools: &Option<Vec<DynamicToolSpec>>,
policies: SendMessagePolicies<'_>,
) -> Result<()> {
with_client(endpoint, config_overrides, |client| {
let initialize = client.initialize_with_experimental_api(experimental_api)?;
println!("< initialize response: {initialize:?}");
with_client(
policies.command_name,
endpoint,
config_overrides,
|client| {
let initialize = client.initialize_with_experimental_api(policies.experimental_api)?;
println!("< initialize response: {initialize:?}");
let thread_response = client.thread_start(ThreadStartParams {
dynamic_tools: dynamic_tools.clone(),
..Default::default()
})?;
println!("< thread/start response: {thread_response:?}");
let mut turn_params = TurnStartParams {
thread_id: thread_response.thread.id.clone(),
input: vec![V2UserInput::Text {
text: user_message,
// Test client sends plain text without UI element ranges.
text_elements: Vec::new(),
}],
..Default::default()
};
turn_params.approval_policy = approval_policy;
turn_params.sandbox_policy = sandbox_policy;
let thread_response = client.thread_start(ThreadStartParams {
dynamic_tools: policies.dynamic_tools.clone(),
..Default::default()
})?;
println!("< thread/start response: {thread_response:?}");
let mut turn_params = TurnStartParams {
thread_id: thread_response.thread.id.clone(),
input: vec![V2UserInput::Text {
text: user_message,
// Test client sends plain text without UI element ranges.
text_elements: Vec::new(),
}],
..Default::default()
};
turn_params.approval_policy = policies.approval_policy;
turn_params.sandbox_policy = policies.sandbox_policy;
let turn_response = client.turn_start(turn_params)?;
println!("< turn/start response: {turn_response:?}");
let turn_response = client.turn_start(turn_params)?;
println!("< turn/start response: {turn_response:?}");
client.stream_turn(&thread_response.thread.id, &turn_response.turn.id)?;
client.stream_turn(&thread_response.thread.id, &turn_response.turn.id)?;
Ok(())
})
Ok(())
},
)
.await
}
fn send_follow_up_v2(
async fn send_follow_up_v2(
endpoint: &Endpoint,
config_overrides: &[String],
first_message: String,
follow_up_message: String,
dynamic_tools: &Option<Vec<DynamicToolSpec>>,
) -> Result<()> {
with_client(endpoint, config_overrides, |client| {
with_client("send-follow-up-v2", endpoint, config_overrides, |client| {
let initialize = client.initialize()?;
println!("< initialize response: {initialize:?}");
@@ -853,10 +910,11 @@ fn send_follow_up_v2(
Ok(())
})
.await
}
fn test_login(endpoint: &Endpoint, config_overrides: &[String]) -> Result<()> {
with_client(endpoint, config_overrides, |client| {
async fn test_login(endpoint: &Endpoint, config_overrides: &[String]) -> Result<()> {
with_client("test-login", endpoint, config_overrides, |client| {
let initialize = client.initialize()?;
println!("< initialize response: {initialize:?}");
@@ -883,22 +941,29 @@ fn test_login(endpoint: &Endpoint, config_overrides: &[String]) -> Result<()> {
);
}
})
.await
}
fn get_account_rate_limits(endpoint: &Endpoint, config_overrides: &[String]) -> Result<()> {
with_client(endpoint, config_overrides, |client| {
let initialize = client.initialize()?;
println!("< initialize response: {initialize:?}");
async fn get_account_rate_limits(endpoint: &Endpoint, config_overrides: &[String]) -> Result<()> {
with_client(
"get-account-rate-limits",
endpoint,
config_overrides,
|client| {
let initialize = client.initialize()?;
println!("< initialize response: {initialize:?}");
let response = client.get_account_rate_limits()?;
println!("< account/rateLimits/read response: {response:?}");
let response = client.get_account_rate_limits()?;
println!("< account/rateLimits/read response: {response:?}");
Ok(())
})
Ok(())
},
)
.await
}
fn model_list(endpoint: &Endpoint, config_overrides: &[String]) -> Result<()> {
with_client(endpoint, config_overrides, |client| {
async fn model_list(endpoint: &Endpoint, config_overrides: &[String]) -> Result<()> {
with_client("model-list", endpoint, config_overrides, |client| {
let initialize = client.initialize()?;
println!("< initialize response: {initialize:?}");
@@ -907,10 +972,11 @@ fn model_list(endpoint: &Endpoint, config_overrides: &[String]) -> Result<()> {
Ok(())
})
.await
}
fn thread_list(endpoint: &Endpoint, config_overrides: &[String], limit: u32) -> Result<()> {
with_client(endpoint, config_overrides, |client| {
async fn thread_list(endpoint: &Endpoint, config_overrides: &[String], limit: u32) -> Result<()> {
with_client("thread-list", endpoint, config_overrides, |client| {
let initialize = client.initialize()?;
println!("< initialize response: {initialize:?}");
@@ -928,16 +994,28 @@ fn thread_list(endpoint: &Endpoint, config_overrides: &[String], limit: u32) ->
Ok(())
})
.await
}
fn with_client<T>(
async fn with_client<T>(
command_name: &'static str,
endpoint: &Endpoint,
config_overrides: &[String],
f: impl FnOnce(&mut CodexClient) -> Result<T>,
) -> Result<T> {
let mut client = CodexClient::connect(endpoint, config_overrides)?;
let result = f(&mut client);
client.print_trace_summary();
let tracing = TestClientTracing::initialize(config_overrides).await?;
let command_span = info_span!(
"app_server_test_client.command",
otel.kind = "client",
otel.name = command_name,
app_server_test_client.command = command_name,
);
let trace_summary = command_span.in_scope(|| TraceSummary::capture(tracing.traces_enabled));
let result = command_span.in_scope(|| {
let mut client = CodexClient::connect(endpoint, config_overrides)?;
f(&mut client)
});
print_trace_summary(&trace_summary);
result
}
@@ -995,8 +1073,6 @@ struct CodexClient {
command_approval_item_ids: Vec<String>,
command_execution_statuses: Vec<CommandExecutionStatus>,
last_turn_status: Option<TurnStatus>,
trace_id: String,
trace_root_span_id: String,
}
#[derive(Debug, Clone, Copy)]
@@ -1056,8 +1132,6 @@ impl CodexClient {
command_approval_item_ids: Vec::new(),
command_execution_statuses: Vec::new(),
last_turn_status: None,
trace_id: generate_trace_id(),
trace_root_span_id: generate_parent_span_id(),
})
}
@@ -1079,8 +1153,6 @@ impl CodexClient {
command_approval_item_ids: Vec::new(),
command_execution_statuses: Vec::new(),
last_turn_status: None,
trace_id: generate_trace_id(),
trace_root_span_id: generate_parent_span_id(),
})
}
@@ -1302,37 +1374,31 @@ impl CodexClient {
where
T: DeserializeOwned,
{
self.write_request(&request)?;
self.wait_for_response(request_id, method)
let request_span = info_span!(
"app_server_test_client.request",
otel.kind = "client",
otel.name = method,
rpc.system = "jsonrpc",
rpc.method = method,
rpc.request_id = ?request_id,
);
request_span.in_scope(|| {
self.write_request(&request)?;
self.wait_for_response(request_id, method)
})
}
fn write_request(&mut self, request: &ClientRequest) -> Result<()> {
let request = self.jsonrpc_request_with_trace(request)?;
let request_value = serde_json::to_value(request)?;
let mut request: JSONRPCRequest = serde_json::from_value(request_value)
.context("client request was not a valid JSON-RPC request")?;
request.trace = current_span_w3c_trace_context();
let request_json = serde_json::to_string(&request)?;
let request_pretty = serde_json::to_string_pretty(&request)?;
print_multiline_with_prefix("> ", &request_pretty);
self.write_payload(&request_json)
}
fn jsonrpc_request_with_trace(&self, request: &ClientRequest) -> Result<JSONRPCRequest> {
let request_value = serde_json::to_value(request)?;
let mut request: JSONRPCRequest = serde_json::from_value(request_value)
.context("client request was not a valid JSON-RPC request")?;
request.trace = Some(W3cTraceContext {
traceparent: Some(format!(
"00-{}-{}-01",
self.trace_id, self.trace_root_span_id
)),
tracestate: None,
});
Ok(request)
}
fn print_trace_summary(&self) {
println!("\n[Datadog trace]");
println!("go/trace/{}\n", self.trace_id);
}
fn wait_for_response<T>(&mut self, request_id: RequestId, method: &str) -> Result<T>
where
T: DeserializeOwned,
@@ -1598,21 +1664,91 @@ impl CodexClient {
}
}
fn generate_trace_id() -> String {
Uuid::new_v4().simple().to_string()
}
fn generate_parent_span_id() -> String {
let uuid = Uuid::new_v4().simple().to_string();
uuid[..16].to_string()
}
fn print_multiline_with_prefix(prefix: &str, payload: &str) {
for line in payload.lines() {
println!("{prefix}{line}");
}
}
struct TestClientTracing {
_otel_provider: Option<OtelProvider>,
traces_enabled: bool,
}
impl TestClientTracing {
async fn initialize(config_overrides: &[String]) -> Result<Self> {
let cli_kv_overrides = CliConfigOverrides {
raw_overrides: config_overrides.to_vec(),
}
.parse_overrides()
.map_err(|e| anyhow::anyhow!("error parsing -c overrides: {e}"))?;
let config = Config::load_with_cli_overrides(cli_kv_overrides)
.await
.context("error loading config")?;
let otel_provider = codex_core::otel_init::build_provider(
&config,
env!("CARGO_PKG_VERSION"),
Some(OTEL_SERVICE_NAME),
DEFAULT_ANALYTICS_ENABLED,
)
.map_err(|e| anyhow::anyhow!("error loading otel config: {e}"))?;
let traces_enabled = otel_provider
.as_ref()
.and_then(|provider| provider.tracer_provider.as_ref())
.is_some();
if let Some(provider) = otel_provider.as_ref()
&& traces_enabled
{
let _ = tracing_subscriber::registry()
.with(provider.tracing_layer())
.try_init();
}
Ok(Self {
traces_enabled,
_otel_provider: otel_provider,
})
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum TraceSummary {
Enabled { url: String },
Disabled,
}
impl TraceSummary {
fn capture(traces_enabled: bool) -> Self {
if !traces_enabled {
return Self::Disabled;
}
current_span_w3c_trace_context()
.as_ref()
.and_then(trace_url_from_context)
.map_or(Self::Disabled, |url| Self::Enabled { url })
}
}
fn trace_url_from_context(trace: &W3cTraceContext) -> Option<String> {
let traceparent = trace.traceparent.as_deref()?;
let mut parts = traceparent.split('-');
match (parts.next(), parts.next(), parts.next(), parts.next()) {
(Some(_version), Some(trace_id), Some(_span_id), Some(_trace_flags))
if trace_id.len() == 32 =>
{
Some(format!("go/trace/{trace_id}"))
}
_ => None,
}
}
fn print_trace_summary(trace_summary: &TraceSummary) {
println!("\n[Datadog trace]");
match trace_summary {
TraceSummary::Enabled { url } => println!("{url}\n"),
TraceSummary::Disabled => println!("{TRACE_DISABLED_MESSAGE}\n"),
}
}
impl Drop for CodexClient {
fn drop(&mut self) {
let ClientTransport::Stdio { child, stdin, .. } = &mut self.transport else {

View File

@@ -1,5 +1,7 @@
use anyhow::Result;
use tokio::runtime::Builder;
fn main() -> Result<()> {
codex_app_server_test_client::run()
let runtime = Builder::new_current_thread().enable_all().build()?;
runtime.block_on(codex_app_server_test_client::run())
}

View File

@@ -153,11 +153,12 @@ Example with notification opt-out:
- `skills/remote/export` — download a remote skill by `hazelnutId` into `skills` under `codex_home` (**under development; do not call from production clients yet**).
- `app/list` — list available apps.
- `skills/config/write` — write user-level skill config by path.
- `plugin/install` — install a plugin from a discovered marketplace entry by `pluginName` and `marketplaceName` (**under development; do not call from production clients yet**).
- `mcpServer/oauth/login` — start an OAuth login for a configured MCP server; returns an `authorization_url` and later emits `mcpServer/oauthLogin/completed` once the browser flow finishes.
- `tool/requestUserInput` — prompt the user with 13 short questions for a tool call and return their answers (experimental).
- `config/mcpServer/reload` — reload MCP server config from disk and queue a refresh for loaded threads (applied on each thread's next active turn); returns `{}`. Use this after editing `config.toml` without restarting the server.
- `mcpServerStatus/list` — enumerate configured MCP servers with their tools, resources, resource templates, and auth status; supports cursor+limit pagination.
- `windowsSandbox/setupStart` — start Windows sandbox setup for the selected mode (`elevated` or `unelevated`); returns `{ started: true }` immediately and later emits `windowsSandbox/setupCompleted`.
- `windowsSandbox/setupStart` — start Windows sandbox setup for the selected mode (`elevated` or `unelevated`); accepts an optional `cwd` to target setup for a specific workspace, returns `{ started: true }` immediately, and later emits `windowsSandbox/setupCompleted`.
- `feedback/upload` — submit a feedback report (classification + optional reason/logs, conversation_id, and optional `extraLogFiles` attachments array); returns the tracking thread id.
- `command/exec` — run a single command under the server sandbox without starting a thread/turn (handy for utilities and validation).
- `config/read` — fetch the effective config on disk after resolving config layering.
@@ -742,7 +743,7 @@ Certain actions (shell commands or modifying files) may require explicit user ap
Order of messages:
1. `item/started` — shows the pending `commandExecution` item with `command`, `cwd`, and other fields so you can render the proposed action.
2. `item/commandExecution/requestApproval` (request) — carries the same `itemId`, `threadId`, `turnId`, optionally `approvalId` (for subcommand callbacks), and `reason`. For normal command approvals, it also includes `command`, `cwd`, and `commandActions` for friendly display. When `initialize.params.capabilities.experimentalApi = true`, it may also include experimental `additionalPermissions` describing requested per-command sandbox access; any filesystem paths in that payload are absolute on the wire. For network-only approvals, those command fields may be omitted and `networkApprovalContext` is provided instead. Optional persistence hints may also be included via `proposedExecpolicyAmendment` and `proposedNetworkPolicyAmendments`. Clients can prefer `availableDecisions` when present to render the exact set of choices the server wants to expose, while still falling back to the older heuristics if it is omitted.
2. `item/commandExecution/requestApproval` (request) — carries the same `itemId`, `threadId`, `turnId`, optionally `approvalId` (for subcommand callbacks), and `reason`. For normal command approvals, it also includes `command`, `cwd`, and `commandActions` for friendly display. When `initialize.params.capabilities.experimentalApi = true`, it may also include experimental `additionalPermissions` describing requested per-command sandbox access; any filesystem paths in that payload are absolute on the wire, and network access is represented as `additionalPermissions.network.enabled`. For network-only approvals, those command fields may be omitted and `networkApprovalContext` is provided instead. Optional persistence hints may also be included via `proposedExecpolicyAmendment` and `proposedNetworkPolicyAmendments`. Clients can prefer `availableDecisions` when present to render the exact set of choices the server wants to expose, while still falling back to the older heuristics if it is omitted.
3. Client response — for example `{ "decision": "accept" }`, `{ "decision": "acceptForSession" }`, `{ "decision": { "acceptWithExecpolicyAmendment": { "execpolicy_amendment": [...] } } }`, `{ "decision": { "applyNetworkPolicyAmendment": { "network_policy_amendment": { "host": "example.com", "action": "allow" } } } }`, `{ "decision": "decline" }`, or `{ "decision": "cancel" }`.
4. `serverRequest/resolved``{ threadId, requestId }` confirms the pending request has been resolved or cleared, including lifecycle cleanup on turn start/complete/interrupt.
5. `item/completed` — final `commandExecution` item with `status: "completed" | "failed" | "declined"` and execution output. Render this as the authoritative result.

View File

@@ -77,6 +77,8 @@ use codex_app_server_protocol::MockExperimentalMethodParams;
use codex_app_server_protocol::MockExperimentalMethodResponse;
use codex_app_server_protocol::ModelListParams;
use codex_app_server_protocol::ModelListResponse;
use codex_app_server_protocol::PluginInstallParams;
use codex_app_server_protocol::PluginInstallResponse;
use codex_app_server_protocol::ProductSurface as ApiProductSurface;
use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::ReviewDelivery as ApiReviewDelivery;
@@ -196,6 +198,8 @@ use codex_core::mcp::collect_mcp_snapshot;
use codex_core::mcp::group_tools_by_server;
use codex_core::models_manager::collaboration_mode_presets::CollaborationModesConfig;
use codex_core::parse_cursor;
use codex_core::plugins::PluginInstallError as CorePluginInstallError;
use codex_core::plugins::PluginInstallRequest;
use codex_core::read_head_for_summary;
use codex_core::read_session_meta_line;
use codex_core::rollout_date_parts;
@@ -658,6 +662,10 @@ impl CodexMessageProcessor {
self.skills_config_write(to_connection_request_id(request_id), params)
.await;
}
ClientRequest::PluginInstall { request_id, params } => {
self.plugin_install(to_connection_request_id(request_id), params)
.await;
}
ClientRequest::TurnStart { request_id, params } => {
self.turn_start(
to_connection_request_id(request_id),
@@ -2693,7 +2701,13 @@ impl CodexMessageProcessor {
}
};
let db_summary = read_summary_from_state_db_by_thread_id(&self.config, thread_uuid).await;
let loaded_thread = self.thread_manager.get_thread(thread_uuid).await.ok();
let loaded_thread_state_db = loaded_thread.as_ref().and_then(|thread| thread.state_db());
let db_summary = if let Some(state_db_ctx) = loaded_thread_state_db.as_ref() {
read_summary_from_state_db_context_by_thread_id(Some(state_db_ctx), thread_uuid).await
} else {
read_summary_from_state_db_by_thread_id(&self.config, thread_uuid).await
};
let mut rollout_path = db_summary.as_ref().map(|summary| summary.path.clone());
if rollout_path.is_none() || include_turns {
rollout_path =
@@ -2747,7 +2761,7 @@ impl CodexMessageProcessor {
}
}
} else {
let Ok(thread) = self.thread_manager.get_thread(thread_uuid).await else {
let Some(thread) = loaded_thread else {
self.send_invalid_request_error(
request_id,
format!("thread not loaded: {thread_uuid}"),
@@ -2952,6 +2966,7 @@ impl CodexMessageProcessor {
};
let fallback_model_provider = config.model_provider_id.clone();
let response_history = thread_history.clone();
match self
.thread_manager
@@ -2965,8 +2980,8 @@ impl CodexMessageProcessor {
{
Ok(NewThread {
thread_id,
thread,
session_configured,
..
}) => {
let SessionConfiguredEvent { rollout_path, .. } = session_configured;
let Some(rollout_path) = rollout_path else {
@@ -2992,9 +3007,11 @@ impl CodexMessageProcessor {
);
let Some(mut thread) = self
.load_thread_from_rollout_or_send_internal(
.load_thread_from_resume_source_or_send_internal(
request_id.clone(),
thread_id,
thread.as_ref(),
&response_history,
rollout_path.as_path(),
fallback_model_provider.as_str(),
)
@@ -3149,6 +3166,20 @@ impl CodexMessageProcessor {
mismatch_details.join("; ")
);
}
let thread_summary = match load_thread_summary_for_rollout(
&self.config,
existing_thread_id,
rollout_path.as_path(),
config_snapshot.model_provider_id.as_str(),
)
.await
{
Ok(thread) => thread,
Err(message) => {
self.send_internal_error(request_id, message).await;
return true;
}
};
let listener_command_tx = {
let thread_state = thread_state.lock().await;
@@ -3169,8 +3200,9 @@ impl CodexMessageProcessor {
let command = crate::thread_state::ThreadListenerCommand::SendThreadResumeResponse(
Box::new(crate::thread_state::PendingThreadResumeRequest {
request_id: request_id.clone(),
rollout_path,
rollout_path: rollout_path.clone(),
config_snapshot,
thread_summary,
}),
);
if listener_command_tx.send(command).is_err() {
@@ -3268,45 +3300,61 @@ impl CodexMessageProcessor {
}
}
async fn load_thread_from_rollout_or_send_internal(
async fn load_thread_from_resume_source_or_send_internal(
&self,
request_id: ConnectionRequestId,
thread_id: ThreadId,
thread: &CodexThread,
thread_history: &InitialHistory,
rollout_path: &Path,
fallback_provider: &str,
) -> Option<Thread> {
let mut thread = match read_summary_from_rollout(rollout_path, fallback_provider).await {
Ok(summary) => summary_to_thread(summary),
Err(err) => {
self.send_internal_error(
request_id,
format!(
"failed to load rollout `{}` for thread {thread_id}: {err}",
rollout_path.display()
),
let thread = match thread_history {
InitialHistory::Resumed(resumed) => {
load_thread_summary_for_rollout(
&self.config,
resumed.conversation_id,
resumed.rollout_path.as_path(),
fallback_provider,
)
.await;
.await
}
InitialHistory::Forked(items) => {
let config_snapshot = thread.config_snapshot().await;
let mut thread = build_thread_from_snapshot(
thread_id,
&config_snapshot,
Some(rollout_path.into()),
);
thread.preview = preview_from_rollout_items(items);
Ok(thread)
}
InitialHistory::New => Err(format!(
"failed to build resume response for thread {thread_id}: initial history missing"
)),
};
let mut thread = match thread {
Ok(thread) => thread,
Err(message) => {
self.send_internal_error(request_id, message).await;
return None;
}
};
match read_rollout_items_from_rollout(rollout_path).await {
Ok(items) => {
thread.turns = build_turns_from_rollout_items(&items);
self.attach_thread_name(thread_id, &mut thread).await;
Some(thread)
}
Err(err) => {
self.send_internal_error(
request_id,
format!(
"failed to load rollout `{}` for thread {thread_id}: {err}",
rollout_path.display()
),
)
.await;
None
}
thread.id = thread_id.to_string();
thread.path = Some(rollout_path.to_path_buf());
let history_items = thread_history.get_rollout_items();
if let Err(message) = populate_resume_turns(
&mut thread,
ResumeTurnSource::HistoryItems(&history_items),
None,
)
.await
{
self.send_internal_error(request_id, message).await;
return None;
}
self.attach_thread_name(thread_id, &mut thread).await;
Some(thread)
}
async fn attach_thread_name(&self, thread_id: ThreadId, thread: &mut Thread) {
@@ -4984,6 +5032,56 @@ impl CodexMessageProcessor {
}
}
async fn plugin_install(&self, request_id: ConnectionRequestId, params: PluginInstallParams) {
let PluginInstallParams {
marketplace_name,
plugin_name,
cwd,
} = params;
let plugins_manager = self.thread_manager.plugins_manager();
let request = PluginInstallRequest {
plugin_name,
marketplace_name,
cwd: cwd.unwrap_or_else(|| self.config.cwd.clone()),
};
match plugins_manager.install_plugin(request).await {
Ok(_) => {
plugins_manager.clear_cache();
self.thread_manager.skills_manager().clear_cache();
self.outgoing
.send_response(request_id, PluginInstallResponse {})
.await;
}
Err(err) => {
if err.is_invalid_request() {
self.send_invalid_request_error(request_id, err.to_string())
.await;
return;
}
match err {
CorePluginInstallError::Config(err) => {
self.send_internal_error(
request_id,
format!("failed to persist installed plugin config: {err}"),
)
.await;
}
CorePluginInstallError::Join(err) => {
self.send_internal_error(
request_id,
format!("failed to install plugin: {err}"),
)
.await;
}
CorePluginInstallError::Marketplace(_) | CorePluginInstallError::Store(_) => {}
}
}
}
}
async fn turn_start(
&self,
request_id: ConnectionRequestId,
@@ -6119,21 +6217,39 @@ impl CodexMessageProcessor {
WindowsSandboxSetupMode::Unelevated => CoreWindowsSandboxSetupMode::Unelevated,
};
let config = Arc::clone(&self.config);
let cli_overrides = self.cli_overrides.clone();
let cloud_requirements = self.current_cloud_requirements();
let command_cwd = params.cwd.unwrap_or_else(|| config.cwd.clone());
let outgoing = Arc::clone(&self.outgoing);
let connection_id = request_id.connection_id;
tokio::spawn(async move {
let setup_request = WindowsSandboxSetupRequest {
mode,
policy: config.permissions.sandbox_policy.get().clone(),
policy_cwd: config.cwd.clone(),
command_cwd: config.cwd.clone(),
env_map: std::env::vars().collect(),
codex_home: config.codex_home.clone(),
active_profile: config.active_profile.clone(),
let derived_config = derive_config_for_cwd(
&cli_overrides,
None,
ConfigOverrides {
cwd: Some(command_cwd.clone()),
..Default::default()
},
Some(command_cwd.clone()),
&cloud_requirements,
)
.await;
let setup_result = match derived_config {
Ok(config) => {
let setup_request = WindowsSandboxSetupRequest {
mode,
policy: config.permissions.sandbox_policy.get().clone(),
policy_cwd: config.cwd.clone(),
command_cwd,
env_map: std::env::vars().collect(),
codex_home: config.codex_home.clone(),
active_profile: config.active_profile.clone(),
};
codex_core::windows_sandbox::run_windows_sandbox_setup(setup_request).await
}
Err(err) => Err(err.into()),
};
let setup_result =
codex_core::windows_sandbox::run_windows_sandbox_setup(setup_request).await;
let notification = WindowsSandboxSetupCompletedNotification {
mode: match mode {
CoreWindowsSandboxSetupMode::Elevated => WindowsSandboxSetupMode::Elevated,
@@ -6224,29 +6340,26 @@ async fn handle_pending_thread_resume_request(
let request_id = pending.request_id;
let connection_id = request_id.connection_id;
let mut thread = match load_thread_for_running_resume_response(
conversation_id,
pending.rollout_path.as_path(),
pending.config_snapshot.model_provider_id.as_str(),
let mut thread = pending.thread_summary;
if let Err(message) = populate_resume_turns(
&mut thread,
ResumeTurnSource::RolloutPath(pending.rollout_path.as_path()),
active_turn.as_ref(),
)
.await
{
Ok(thread) => thread,
Err(message) => {
outgoing
.send_error(
request_id,
JSONRPCErrorError {
code: INTERNAL_ERROR_CODE,
message,
data: None,
},
)
.await;
return;
}
};
outgoing
.send_error(
request_id,
JSONRPCErrorError {
code: INTERNAL_ERROR_CODE,
message,
data: None,
},
)
.await;
return;
}
has_in_progress_turn = has_in_progress_turn
|| thread
@@ -6296,6 +6409,38 @@ async fn handle_pending_thread_resume_request(
.await;
}
enum ResumeTurnSource<'a> {
RolloutPath(&'a Path),
HistoryItems(&'a [RolloutItem]),
}
async fn populate_resume_turns(
thread: &mut Thread,
turn_source: ResumeTurnSource<'_>,
active_turn: Option<&Turn>,
) -> std::result::Result<(), String> {
let mut turns = match turn_source {
ResumeTurnSource::RolloutPath(rollout_path) => {
read_rollout_items_from_rollout(rollout_path)
.await
.map(|items| build_turns_from_rollout_items(&items))
.map_err(|err| {
format!(
"failed to load rollout `{}` for thread {}: {err}",
rollout_path.display(),
thread.id
)
})?
}
ResumeTurnSource::HistoryItems(items) => build_turns_from_rollout_items(items),
};
if let Some(active_turn) = active_turn {
merge_turn_history_with_active_turn(&mut turns, active_turn.clone());
}
thread.turns = turns;
Ok(())
}
async fn resolve_pending_server_request(
conversation_id: ThreadId,
thread_state_manager: &ThreadStateManager,
@@ -6321,38 +6466,6 @@ async fn resolve_pending_server_request(
.await;
}
async fn load_thread_for_running_resume_response(
conversation_id: ThreadId,
rollout_path: &Path,
fallback_provider: &str,
active_turn: Option<&Turn>,
) -> std::result::Result<Thread, String> {
let mut thread = read_summary_from_rollout(rollout_path, fallback_provider)
.await
.map(summary_to_thread)
.map_err(|err| {
format!(
"failed to load rollout `{}` for thread {conversation_id}: {err}",
rollout_path.display()
)
})?;
let mut turns = read_rollout_items_from_rollout(rollout_path)
.await
.map(|items| build_turns_from_rollout_items(&items))
.map_err(|err| {
format!(
"failed to load rollout `{}` for thread {conversation_id}: {err}",
rollout_path.display()
)
})?;
if let Some(active_turn) = active_turn {
merge_turn_history_with_active_turn(&mut turns, active_turn.clone());
}
thread.turns = turns;
Ok(thread)
}
fn merge_turn_history_with_active_turn(turns: &mut Vec<Turn>, active_turn: Turn) {
turns.retain(|turn| turn.id != active_turn.id);
turns.push(active_turn);
@@ -6950,6 +7063,48 @@ fn map_git_info(git_info: &CoreGitInfo) -> ConversationGitInfo {
}
}
async fn load_thread_summary_for_rollout(
config: &Config,
thread_id: ThreadId,
rollout_path: &Path,
fallback_provider: &str,
) -> std::result::Result<Thread, String> {
let mut thread = read_summary_from_rollout(rollout_path, fallback_provider)
.await
.map(summary_to_thread)
.map_err(|err| {
format!(
"failed to load rollout `{}` for thread {thread_id}: {err}",
rollout_path.display()
)
})?;
if let Some(summary) = read_summary_from_state_db_by_thread_id(config, thread_id).await {
merge_mutable_thread_metadata(&mut thread, summary_to_thread(summary));
}
Ok(thread)
}
fn merge_mutable_thread_metadata(thread: &mut Thread, persisted_thread: Thread) {
thread.git_info = persisted_thread.git_info;
}
fn preview_from_rollout_items(items: &[RolloutItem]) -> String {
items
.iter()
.find_map(|item| match item {
RolloutItem::ResponseItem(item) => match codex_core::parse_turn_item(item) {
Some(codex_protocol::items::TurnItem::UserMessage(user)) => Some(user.message()),
_ => None,
},
_ => None,
})
.map(|preview| match preview.find(USER_MESSAGE_BEGIN) {
Some(idx) => preview[idx + USER_MESSAGE_BEGIN.len()..].trim().to_string(),
None => preview,
})
.unwrap_or_default()
}
fn with_thread_spawn_agent_metadata(
source: codex_protocol::protocol::SessionSource,
agent_nickname: Option<String>,

View File

@@ -26,6 +26,7 @@ pub(crate) struct PendingThreadResumeRequest {
pub(crate) request_id: ConnectionRequestId,
pub(crate) rollout_path: PathBuf,
pub(crate) config_snapshot: ThreadConfigSnapshot,
pub(crate) thread_summary: codex_app_server_protocol::Thread,
}
// ThreadListenerCommand is used to perform operations in the context of the thread listener, for serialization purposes.

View File

@@ -23,6 +23,8 @@ use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::ServerRequest;
use codex_app_server_protocol::SessionSource;
use codex_app_server_protocol::ThreadItem;
use codex_app_server_protocol::ThreadMetadataGitInfoUpdateParams;
use codex_app_server_protocol::ThreadMetadataUpdateParams;
use codex_app_server_protocol::ThreadResumeParams;
use codex_app_server_protocol::ThreadResumeResponse;
use codex_app_server_protocol::ThreadStartParams;
@@ -32,19 +34,27 @@ use codex_app_server_protocol::TurnStartParams;
use codex_app_server_protocol::TurnStartResponse;
use codex_app_server_protocol::TurnStatus;
use codex_app_server_protocol::UserInput;
use codex_protocol::ThreadId;
use codex_protocol::config_types::Personality;
use codex_protocol::models::ContentItem;
use codex_protocol::models::ResponseItem;
use codex_protocol::protocol::SessionMeta;
use codex_protocol::protocol::SessionMetaLine;
use codex_protocol::protocol::SessionSource as RolloutSessionSource;
use codex_protocol::user_input::ByteRange;
use codex_protocol::user_input::TextElement;
use codex_state::StateRuntime;
use core_test_support::responses;
use core_test_support::skip_if_no_network;
use pretty_assertions::assert_eq;
use serde_json::json;
use std::fs::FileTimes;
use std::path::Path;
use std::path::PathBuf;
use std::process::Command;
use tempfile::TempDir;
use tokio::time::timeout;
use uuid::Uuid;
const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
const CODEX_5_2_INSTRUCTIONS_TEMPLATE_DEFAULT: &str = "You are Codex, a coding agent based on GPT-5. You and the user share the same workspace and collaborate to achieve the user's goals.";
@@ -170,6 +180,198 @@ async fn thread_resume_returns_rollout_history() -> Result<()> {
Ok(())
}
#[tokio::test]
async fn thread_resume_prefers_persisted_git_metadata_for_local_threads() -> Result<()> {
let server = create_mock_responses_server_repeating_assistant("Done").await;
let codex_home = TempDir::new()?;
let config_toml = codex_home.path().join("config.toml");
std::fs::write(
&config_toml,
format!(
r#"
model = "gpt-5.2-codex"
approval_policy = "never"
sandbox_mode = "read-only"
model_provider = "mock_provider"
[features]
personality = true
sqlite = true
[model_providers.mock_provider]
name = "Mock provider for test"
base_url = "{}/v1"
wire_api = "responses"
request_max_retries = 0
stream_max_retries = 0
"#,
server.uri()
),
)?;
let repo_path = codex_home.path().join("repo");
std::fs::create_dir_all(&repo_path)?;
assert!(
Command::new("git")
.args(["init"])
.arg(&repo_path)
.status()?
.success()
);
assert!(
Command::new("git")
.current_dir(&repo_path)
.args(["checkout", "-B", "master"])
.status()?
.success()
);
assert!(
Command::new("git")
.current_dir(&repo_path)
.args(["config", "user.name", "Test User"])
.status()?
.success()
);
assert!(
Command::new("git")
.current_dir(&repo_path)
.args(["config", "user.email", "test@example.com"])
.status()?
.success()
);
std::fs::write(repo_path.join("README.md"), "test\n")?;
assert!(
Command::new("git")
.current_dir(&repo_path)
.args(["add", "README.md"])
.status()?
.success()
);
assert!(
Command::new("git")
.current_dir(&repo_path)
.args(["commit", "-m", "initial"])
.status()?
.success()
);
let head_branch = Command::new("git")
.current_dir(&repo_path)
.args(["branch", "--show-current"])
.output()?;
assert_eq!(
String::from_utf8(head_branch.stdout)?.trim(),
"master",
"test repo should stay on master to verify resume ignores live HEAD"
);
let thread_id = Uuid::new_v4().to_string();
let conversation_id = ThreadId::from_string(&thread_id)?;
let rollout_path = rollout_path(codex_home.path(), "2025-01-05T12-00-00", &thread_id);
let rollout_dir = rollout_path.parent().expect("rollout parent directory");
std::fs::create_dir_all(rollout_dir)?;
let session_meta = SessionMeta {
id: conversation_id,
forked_from_id: None,
timestamp: "2025-01-05T12:00:00Z".to_string(),
cwd: repo_path.clone(),
originator: "codex".to_string(),
cli_version: "0.0.0".to_string(),
source: RolloutSessionSource::Cli,
agent_nickname: None,
agent_role: None,
model_provider: Some("mock_provider".to_string()),
base_instructions: None,
dynamic_tools: None,
memory_mode: None,
};
std::fs::write(
&rollout_path,
[
json!({
"timestamp": "2025-01-05T12:00:00Z",
"type": "session_meta",
"payload": serde_json::to_value(SessionMetaLine {
meta: session_meta,
git: None,
})?,
})
.to_string(),
json!({
"timestamp": "2025-01-05T12:00:00Z",
"type": "response_item",
"payload": {
"type": "message",
"role": "user",
"content": [{"type": "input_text", "text": "Saved user message"}]
}
})
.to_string(),
json!({
"timestamp": "2025-01-05T12:00:00Z",
"type": "event_msg",
"payload": {
"type": "user_message",
"message": "Saved user message",
"kind": "plain"
}
})
.to_string(),
]
.join("\n")
+ "\n",
)?;
let state_db = StateRuntime::init(
codex_home.path().to_path_buf(),
"mock_provider".into(),
None,
)
.await?;
state_db.mark_backfill_complete(None).await?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let update_id = mcp
.send_thread_metadata_update_request(ThreadMetadataUpdateParams {
thread_id: thread_id.clone(),
git_info: Some(ThreadMetadataGitInfoUpdateParams {
sha: None,
branch: Some(Some("feature/pr-branch".to_string())),
origin_url: None,
}),
})
.await?;
timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(update_id)),
)
.await??;
let resume_id = mcp
.send_thread_resume_request(ThreadResumeParams {
thread_id,
..Default::default()
})
.await?;
let resume_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(resume_id)),
)
.await??;
let ThreadResumeResponse { thread, .. } = to_response::<ThreadResumeResponse>(resume_resp)?;
assert_eq!(
thread
.git_info
.as_ref()
.and_then(|git| git.branch.as_deref()),
Some("feature/pr-branch")
);
Ok(())
}
#[tokio::test]
async fn thread_resume_without_overrides_does_not_change_updated_at_or_mtime() -> Result<()> {
let server = create_mock_responses_server_repeating_assistant("Done").await;

View File

@@ -12,6 +12,7 @@ use codex_app_server_protocol::ThreadStartedNotification;
use codex_app_server_protocol::ThreadStatus;
use codex_app_server_protocol::ThreadStatusChangedNotification;
use codex_core::config::set_project_trust_level;
use codex_protocol::config_types::ServiceTier;
use codex_protocol::config_types::TrustLevel;
use codex_protocol::openai_models::ReasoningEffort;
use pretty_assertions::assert_eq;
@@ -180,6 +181,34 @@ model_reasoning_effort = "high"
Ok(())
}
#[tokio::test]
async fn thread_start_accepts_flex_service_tier() -> Result<()> {
let server = create_mock_responses_server_repeating_assistant("Done").await;
let codex_home = TempDir::new()?;
create_config_toml(codex_home.path(), &server.uri())?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let req_id = mcp
.send_thread_start_request(ThreadStartParams {
service_tier: Some(Some(ServiceTier::Flex)),
..Default::default()
})
.await?;
let resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(req_id)),
)
.await??;
let ThreadStartResponse { service_tier, .. } = to_response::<ThreadStartResponse>(resp)?;
assert_eq!(service_tier, Some(ServiceTier::Flex));
Ok(())
}
#[tokio::test]
async fn thread_start_accepts_metrics_service_name() -> Result<()> {
let server = create_mock_responses_server_repeating_assistant("Done").await;

View File

@@ -11,6 +11,7 @@ use app_test_support::McpProcess;
use app_test_support::create_final_assistant_message_sse_response;
use app_test_support::create_mock_responses_server_sequence;
use app_test_support::create_mock_responses_server_sequence_unchecked;
use app_test_support::create_shell_command_sse_response;
use app_test_support::to_response;
use codex_app_server_protocol::CommandAction;
use codex_app_server_protocol::CommandExecutionApprovalDecision;
@@ -34,11 +35,9 @@ use codex_core::features::Feature;
use core_test_support::responses;
use core_test_support::skip_if_no_network;
use pretty_assertions::assert_eq;
use serde_json::json;
use std::collections::BTreeMap;
use std::path::Path;
use tempfile::TempDir;
use tokio::time::sleep;
use tokio::time::timeout;
#[cfg(windows)]
@@ -62,8 +61,10 @@ async fn turn_start_shell_zsh_fork_executes_command_v2() -> Result<()> {
};
eprintln!("using zsh path for zsh-fork test: {}", zsh_path.display());
let responses = vec![create_zsh_fork_exec_command_sse_response(
"echo hi",
let responses = vec![create_shell_command_sse_response(
vec!["echo".to_string(), "hi".to_string()],
None,
Some(5000),
"call-zsh-fork",
)?];
let server = create_mock_responses_server_sequence(responses).await;
@@ -73,7 +74,7 @@ async fn turn_start_shell_zsh_fork_executes_command_v2() -> Result<()> {
"never",
&BTreeMap::from([
(Feature::ShellZshFork, true),
(Feature::UnifiedExec, true),
(Feature::UnifiedExec, false),
(Feature::ShellSnapshot, false),
]),
&zsh_path,
@@ -171,8 +172,14 @@ async fn turn_start_shell_zsh_fork_exec_approval_decline_v2() -> Result<()> {
eprintln!("using zsh path for zsh-fork test: {}", zsh_path.display());
let responses = vec![
create_zsh_fork_exec_command_sse_response(
"python3 -c 'print(42)'",
create_shell_command_sse_response(
vec![
"python3".to_string(),
"-c".to_string(),
"print(42)".to_string(),
],
None,
Some(5000),
"call-zsh-fork-decline",
)?,
create_final_assistant_message_sse_response("done")?,
@@ -184,7 +191,7 @@ async fn turn_start_shell_zsh_fork_exec_approval_decline_v2() -> Result<()> {
"untrusted",
&BTreeMap::from([
(Feature::ShellZshFork, true),
(Feature::UnifiedExec, true),
(Feature::UnifiedExec, false),
(Feature::ShellSnapshot, false),
]),
&zsh_path,
@@ -300,8 +307,14 @@ async fn turn_start_shell_zsh_fork_exec_approval_cancel_v2() -> Result<()> {
};
eprintln!("using zsh path for zsh-fork test: {}", zsh_path.display());
let responses = vec![create_zsh_fork_exec_command_sse_response(
"python3 -c 'print(42)'",
let responses = vec![create_shell_command_sse_response(
vec![
"python3".to_string(),
"-c".to_string(),
"print(42)".to_string(),
],
None,
Some(5000),
"call-zsh-fork-cancel",
)?];
let server = create_mock_responses_server_sequence(responses).await;
@@ -311,7 +324,7 @@ async fn turn_start_shell_zsh_fork_exec_approval_cancel_v2() -> Result<()> {
"untrusted",
&BTreeMap::from([
(Feature::ShellZshFork, true),
(Feature::UnifiedExec, true),
(Feature::UnifiedExec, false),
(Feature::ShellSnapshot, false),
]),
&zsh_path,
@@ -409,181 +422,6 @@ async fn turn_start_shell_zsh_fork_exec_approval_cancel_v2() -> Result<()> {
Ok(())
}
#[tokio::test]
async fn turn_start_shell_zsh_fork_interrupt_kills_approved_subcommand_v2() -> Result<()> {
skip_if_no_network!(Ok(()));
let tmp = TempDir::new()?;
let codex_home = tmp.path().join("codex_home");
std::fs::create_dir(&codex_home)?;
let workspace = tmp.path().join("workspace");
std::fs::create_dir(&workspace)?;
let pid_file = workspace.join("approved-subcommand.pid");
let pid_file_display = pid_file.display().to_string();
assert!(
!pid_file_display.contains('\''),
"test workspace path should not contain single quotes: {pid_file_display}"
);
let Some(zsh_path) = find_test_zsh_path()? else {
eprintln!("skipping zsh fork interrupt cleanup test: no zsh executable found");
return Ok(());
};
if !supports_exec_wrapper_intercept(&zsh_path) {
eprintln!(
"skipping zsh fork interrupt cleanup test: zsh does not support EXEC_WRAPPER intercepts ({})",
zsh_path.display()
);
return Ok(());
}
let zsh_path_display = zsh_path.display().to_string();
eprintln!("using zsh path for zsh-fork test: {zsh_path_display}");
let shell_command =
format!("/bin/sh -c 'echo $$ > \"{pid_file_display}\" && exec /bin/sleep 100'");
let tool_call_arguments = serde_json::to_string(&json!({
"cmd": shell_command,
"yield_time_ms": 30_000,
}))?;
let response = responses::sse(vec![
responses::ev_response_created("resp-1"),
responses::ev_function_call(
"call-zsh-fork-interrupt-cleanup",
"exec_command",
&tool_call_arguments,
),
responses::ev_completed("resp-1"),
]);
let no_op_response = responses::sse(vec![
responses::ev_response_created("resp-2"),
responses::ev_completed("resp-2"),
]);
let server =
create_mock_responses_server_sequence_unchecked(vec![response, no_op_response]).await;
create_config_toml(
&codex_home,
&server.uri(),
"untrusted",
&BTreeMap::from([
(Feature::ShellZshFork, true),
(Feature::UnifiedExec, true),
(Feature::ShellSnapshot, false),
]),
&zsh_path,
)?;
let mut mcp = create_zsh_test_mcp_process(&codex_home, &workspace).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let start_id = mcp
.send_thread_start_request(ThreadStartParams {
model: Some("mock-model".to_string()),
cwd: Some(workspace.to_string_lossy().into_owned()),
..Default::default()
})
.await?;
let start_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(start_id)),
)
.await??;
let ThreadStartResponse { thread, .. } = to_response::<ThreadStartResponse>(start_resp)?;
let turn_id = mcp
.send_turn_start_request(TurnStartParams {
thread_id: thread.id.clone(),
input: vec![V2UserInput::Text {
text: "run the long-lived command".to_string(),
text_elements: Vec::new(),
}],
cwd: Some(workspace.clone()),
approval_policy: Some(codex_app_server_protocol::AskForApproval::UnlessTrusted),
sandbox_policy: Some(codex_app_server_protocol::SandboxPolicy::WorkspaceWrite {
writable_roots: vec![workspace.clone().try_into()?],
read_only_access: codex_app_server_protocol::ReadOnlyAccess::FullAccess,
network_access: false,
exclude_tmpdir_env_var: false,
exclude_slash_tmp: false,
}),
model: Some("mock-model".to_string()),
effort: Some(codex_protocol::openai_models::ReasoningEffort::Medium),
summary: Some(codex_protocol::config_types::ReasoningSummary::Auto),
..Default::default()
})
.await?;
let turn_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(turn_id)),
)
.await??;
let TurnStartResponse { turn } = to_response::<TurnStartResponse>(turn_resp)?;
let mut saw_target_approval = false;
while !saw_target_approval {
let server_req = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_request_message(),
)
.await??;
let ServerRequest::CommandExecutionRequestApproval { request_id, params } = server_req
else {
panic!("expected CommandExecutionRequestApproval request");
};
let approval_command = params.command.clone().unwrap_or_default();
saw_target_approval = approval_command.contains("/bin/sh")
&& approval_command.contains(&pid_file_display)
&& !approval_command.contains(&zsh_path_display);
mcp.send_response(
request_id,
serde_json::to_value(CommandExecutionRequestApprovalResponse {
decision: CommandExecutionApprovalDecision::Accept,
})?,
)
.await?;
}
let pid = timeout(DEFAULT_READ_TIMEOUT, async {
loop {
if let Ok(contents) = std::fs::read_to_string(&pid_file) {
return Ok::<i32, anyhow::Error>(contents.trim().parse()?);
}
sleep(std::time::Duration::from_millis(20)).await;
}
})
.await??;
let still_running = std::process::Command::new("/bin/kill")
.args(["-0", &pid.to_string()])
.status()?
.success();
assert!(
still_running,
"expected approved intercepted subprocess pid {pid} to be running before interrupt"
);
mcp.interrupt_turn_and_wait_for_aborted(
thread.id.clone(),
turn.id.clone(),
DEFAULT_READ_TIMEOUT,
)
.await?;
timeout(DEFAULT_READ_TIMEOUT, async {
loop {
let still_running = std::process::Command::new("/bin/kill")
.args(["-0", &pid.to_string()])
.status()?
.success();
if !still_running {
return Ok::<(), anyhow::Error>(());
}
sleep(std::time::Duration::from_millis(20)).await;
}
})
.await??;
Ok(())
}
#[tokio::test]
async fn turn_start_shell_zsh_fork_subcommand_decline_marks_parent_declined_v2() -> Result<()> {
skip_if_no_network!(Ok(()));
@@ -615,15 +453,16 @@ async fn turn_start_shell_zsh_fork_subcommand_decline_marks_parent_declined_v2()
first_file.display(),
second_file.display()
);
let tool_call_arguments = serde_json::to_string(&json!({
"cmd": shell_command,
"yield_time_ms": 5000,
let tool_call_arguments = serde_json::to_string(&serde_json::json!({
"command": shell_command,
"workdir": serde_json::Value::Null,
"timeout_ms": 5000
}))?;
let response = responses::sse(vec![
responses::ev_response_created("resp-1"),
responses::ev_function_call(
"call-zsh-fork-subcommand-decline",
"exec_command",
"shell_command",
&tool_call_arguments,
),
responses::ev_completed("resp-1"),
@@ -644,7 +483,7 @@ async fn turn_start_shell_zsh_fork_subcommand_decline_marks_parent_declined_v2()
"untrusted",
&BTreeMap::from([
(Feature::ShellZshFork, true),
(Feature::UnifiedExec, true),
(Feature::UnifiedExec, false),
(Feature::ShellSnapshot, false),
]),
&zsh_path,
@@ -855,21 +694,6 @@ async fn create_zsh_test_mcp_process(codex_home: &Path, zdotdir: &Path) -> Resul
McpProcess::new_with_env(codex_home, &[("ZDOTDIR", Some(zdotdir.as_str()))]).await
}
fn create_zsh_fork_exec_command_sse_response(
command: &str,
call_id: &str,
) -> anyhow::Result<String> {
let tool_call_arguments = serde_json::to_string(&json!({
"cmd": command,
"yield_time_ms": 5000,
}))?;
Ok(responses::sse(vec![
responses::ev_response_created("resp-1"),
responses::ev_function_call(call_id, "exec_command", &tool_call_arguments),
responses::ev_completed("resp-1"),
]))
}
fn create_config_toml(
codex_home: &Path,
server_uri: &str,

View File

@@ -37,6 +37,7 @@ async fn windows_sandbox_setup_start_emits_completion_notification() -> Result<(
let request_id = mcp
.send_windows_sandbox_setup_start_request(WindowsSandboxSetupStartParams {
mode: WindowsSandboxSetupMode::Unelevated,
cwd: None,
})
.await?;
let response: JSONRPCResponse = timeout(

View File

@@ -1,6 +0,0 @@
load("//:defs.bzl", "codex_rust_crate")
codex_rust_crate(
name = "artifact-presentation",
crate_name = "codex_artifact_presentation",
)

View File

@@ -1,28 +0,0 @@
[package]
name = "codex-artifact-presentation"
version.workspace = true
edition.workspace = true
license.workspace = true
[lib]
name = "codex_artifact_presentation"
path = "src/lib.rs"
[lints]
workspace = true
[dependencies]
base64 = { workspace = true }
image = { workspace = true, features = ["jpeg", "png"] }
ppt-rs = { workspace = true }
reqwest = { workspace = true, features = ["blocking"] }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
thiserror = { workspace = true }
uuid = { workspace = true, features = ["v4"] }
zip = { workspace = true }
[dev-dependencies]
pretty_assertions = { workspace = true }
tempfile = { workspace = true }
tiny_http = { workspace = true }

View File

@@ -1,6 +0,0 @@
mod presentation_artifact;
#[cfg(test)]
mod tests;
pub use presentation_artifact::*;

View File

@@ -1,249 +0,0 @@
use base64::Engine;
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
use image::GenericImageView;
use image::ImageFormat;
use image::codecs::jpeg::JpegEncoder;
use image::imageops::FilterType;
use ppt_rs::Chart;
use ppt_rs::ChartSeries;
use ppt_rs::ChartType;
use ppt_rs::Hyperlink as PptHyperlink;
use ppt_rs::HyperlinkAction as PptHyperlinkAction;
use ppt_rs::Image;
use ppt_rs::Presentation;
use ppt_rs::Shape;
use ppt_rs::ShapeFill;
use ppt_rs::ShapeLine;
use ppt_rs::ShapeType;
use ppt_rs::SlideContent;
use ppt_rs::SlideLayout;
use ppt_rs::TableBuilder;
use ppt_rs::TableCell;
use ppt_rs::TableRow;
use ppt_rs::generator::ArrowSize;
use ppt_rs::generator::ArrowType;
use ppt_rs::generator::CellAlign;
use ppt_rs::generator::Connector;
use ppt_rs::generator::ConnectorLine;
use ppt_rs::generator::ConnectorType;
use ppt_rs::generator::LineDash;
use ppt_rs::generator::generate_image_content_type;
use serde::Deserialize;
use serde::Serialize;
use serde_json::Value;
use std::collections::BTreeSet;
use std::collections::HashMap;
use std::collections::HashSet;
use std::io::Cursor;
use std::io::Read;
use std::io::Seek;
use std::io::Write;
use std::path::Path;
use std::path::PathBuf;
use std::process::Command;
use thiserror::Error;
use uuid::Uuid;
use zip::ZipArchive;
use zip::ZipWriter;
use zip::write::SimpleFileOptions;
const POINT_TO_EMU: u32 = 12_700;
const DEFAULT_SLIDE_WIDTH_POINTS: u32 = 720;
const DEFAULT_SLIDE_HEIGHT_POINTS: u32 = 540;
const DEFAULT_IMPORTED_TITLE_LEFT: u32 = 36;
const DEFAULT_IMPORTED_TITLE_TOP: u32 = 24;
const DEFAULT_IMPORTED_TITLE_WIDTH: u32 = 648;
const DEFAULT_IMPORTED_TITLE_HEIGHT: u32 = 48;
const DEFAULT_IMPORTED_CONTENT_LEFT: u32 = 48;
const DEFAULT_IMPORTED_CONTENT_TOP: u32 = 96;
const DEFAULT_IMPORTED_CONTENT_WIDTH: u32 = 624;
const DEFAULT_IMPORTED_CONTENT_HEIGHT: u32 = 324;
#[derive(Debug, Error)]
pub enum PresentationArtifactError {
#[error("missing `artifact_id` for action `{action}`")]
MissingArtifactId { action: String },
#[error("unknown artifact id `{artifact_id}` for action `{action}`")]
UnknownArtifactId { action: String, artifact_id: String },
#[error("unknown action `{0}`")]
UnknownAction(String),
#[error("invalid args for action `{action}`: {message}")]
InvalidArgs { action: String, message: String },
#[error("unsupported feature for action `{action}`: {message}")]
UnsupportedFeature { action: String, message: String },
#[error("failed to import PPTX `{path}`: {message}")]
ImportFailed { path: PathBuf, message: String },
#[error("failed to export PPTX `{path}`: {message}")]
ExportFailed { path: PathBuf, message: String },
}
#[derive(Debug, Clone, Deserialize)]
pub struct PresentationArtifactRequest {
pub artifact_id: Option<String>,
pub action: String,
#[serde(default)]
pub args: Value,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PresentationArtifactToolRequest {
pub artifact_id: Option<String>,
pub actions: Vec<PresentationArtifactToolAction>,
}
#[derive(Debug, Clone)]
pub struct PresentationArtifactExecutionRequest {
pub artifact_id: Option<String>,
pub requests: Vec<PresentationArtifactRequest>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PresentationArtifactToolAction {
pub action: String,
#[serde(default)]
pub args: Value,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PathAccessKind {
Read,
Write,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PathAccessRequirement {
pub action: String,
pub kind: PathAccessKind,
pub path: PathBuf,
}
impl PresentationArtifactRequest {
pub fn is_mutating(&self) -> bool {
!is_read_only_action(&self.action)
}
pub fn required_path_accesses(
&self,
cwd: &Path,
) -> Result<Vec<PathAccessRequirement>, PresentationArtifactError> {
let access = match self.action.as_str() {
"import_pptx" => {
let args: ImportPptxArgs = parse_args(&self.action, &self.args)?;
vec![PathAccessRequirement {
action: self.action.clone(),
kind: PathAccessKind::Read,
path: resolve_path(cwd, &args.path),
}]
}
"export_pptx" => {
let args: ExportPptxArgs = parse_args(&self.action, &self.args)?;
vec![PathAccessRequirement {
action: self.action.clone(),
kind: PathAccessKind::Write,
path: resolve_path(cwd, &args.path),
}]
}
"export_preview" => {
let args: ExportPreviewArgs = parse_args(&self.action, &self.args)?;
vec![PathAccessRequirement {
action: self.action.clone(),
kind: PathAccessKind::Write,
path: resolve_path(cwd, &args.path),
}]
}
"add_image" => {
let args: AddImageArgs = parse_args(&self.action, &self.args)?;
match args.image_source()? {
ImageInputSource::Path(path) => vec![PathAccessRequirement {
action: self.action.clone(),
kind: PathAccessKind::Read,
path: resolve_path(cwd, &path),
}],
ImageInputSource::DataUrl(_)
| ImageInputSource::Blob(_)
| ImageInputSource::Uri(_)
| ImageInputSource::Placeholder => Vec::new(),
}
}
"replace_image" => {
let args: ReplaceImageArgs = parse_args(&self.action, &self.args)?;
match (
&args.path,
&args.data_url,
&args.blob,
&args.uri,
&args.prompt,
) {
(Some(path), None, None, None, None) => vec![PathAccessRequirement {
action: self.action.clone(),
kind: PathAccessKind::Read,
path: resolve_path(cwd, path),
}],
(None, Some(_), None, None, None)
| (None, None, Some(_), None, None)
| (None, None, None, Some(_), None)
| (None, None, None, None, Some(_)) => Vec::new(),
_ => {
return Err(PresentationArtifactError::InvalidArgs {
action: self.action.clone(),
message:
"provide exactly one of `path`, `data_url`, `blob`, or `uri`, or provide `prompt` for a placeholder image"
.to_string(),
});
}
}
}
_ => Vec::new(),
};
Ok(access)
}
}
impl PresentationArtifactToolRequest {
pub fn is_mutating(&self) -> Result<bool, PresentationArtifactError> {
Ok(self.actions.iter().any(|request| !is_read_only_action(&request.action)))
}
pub fn into_execution_request(
self,
) -> Result<PresentationArtifactExecutionRequest, PresentationArtifactError> {
if self.actions.is_empty() {
return Err(PresentationArtifactError::InvalidArgs {
action: "presentation_artifact".to_string(),
message: "`actions` must contain at least one item".to_string(),
});
}
Ok(PresentationArtifactExecutionRequest {
artifact_id: self.artifact_id,
requests: self
.actions
.into_iter()
.map(|request| PresentationArtifactRequest {
artifact_id: None,
action: request.action,
args: request.args,
})
.collect(),
})
}
pub fn required_path_accesses(
&self,
cwd: &Path,
) -> Result<Vec<PathAccessRequirement>, PresentationArtifactError> {
let mut accesses = Vec::new();
for request in &self.actions {
accesses.extend(
PresentationArtifactRequest {
artifact_id: None,
action: request.action.clone(),
args: request.args.clone(),
}
.required_path_accesses(cwd)?,
);
}
Ok(accesses)
}
}

View File

@@ -1,729 +0,0 @@
#[derive(Debug, Deserialize)]
struct CreateArgs {
name: Option<String>,
slide_size: Option<Value>,
theme: Option<ThemeArgs>,
}
#[derive(Debug, Deserialize)]
struct ImportPptxArgs {
path: PathBuf,
}
#[derive(Debug, Deserialize)]
struct ExportPptxArgs {
path: PathBuf,
}
#[derive(Debug, Deserialize)]
struct ExportPreviewArgs {
path: PathBuf,
slide_index: Option<u32>,
format: Option<String>,
scale: Option<f32>,
quality: Option<u8>,
}
#[derive(Debug, Default, Deserialize)]
struct AddSlideArgs {
layout: Option<String>,
notes: Option<String>,
background_fill: Option<String>,
}
#[derive(Debug, Deserialize)]
struct CreateLayoutArgs {
name: String,
kind: Option<String>,
parent_layout_id: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum PreviewOutputFormat {
Png,
Jpeg,
Svg,
}
impl PreviewOutputFormat {
fn extension(self) -> &'static str {
match self {
Self::Png => "png",
Self::Jpeg => "jpg",
Self::Svg => "svg",
}
}
}
#[derive(Debug, Deserialize)]
struct AddLayoutPlaceholderArgs {
layout_id: String,
name: String,
placeholder_type: String,
index: Option<u32>,
text: Option<String>,
geometry: Option<String>,
position: Option<PositionArgs>,
}
#[derive(Debug, Deserialize)]
struct LayoutIdArgs {
layout_id: String,
}
#[derive(Debug, Deserialize)]
struct SetSlideLayoutArgs {
slide_index: u32,
layout_id: String,
}
#[derive(Debug, Deserialize)]
struct UpdatePlaceholderTextArgs {
slide_index: u32,
name: String,
text: String,
}
#[derive(Debug, Deserialize)]
struct NotesArgs {
slide_index: u32,
text: Option<String>,
}
#[derive(Debug, Deserialize)]
struct NotesVisibilityArgs {
slide_index: u32,
visible: bool,
}
#[derive(Debug, Deserialize)]
struct ThemeArgs {
color_scheme: HashMap<String, String>,
major_font: Option<String>,
minor_font: Option<String>,
}
#[derive(Debug, Deserialize)]
struct StyleNameArgs {
name: String,
}
#[derive(Debug, Deserialize)]
struct AddStyleArgs {
name: String,
#[serde(flatten)]
styling: TextStylingArgs,
}
#[derive(Debug, Deserialize)]
struct InspectArgs {
kind: Option<String>,
include: Option<String>,
exclude: Option<String>,
search: Option<String>,
target_id: Option<String>,
target: Option<InspectTargetArgs>,
max_chars: Option<usize>,
}
#[derive(Debug, Clone, Deserialize)]
struct InspectTargetArgs {
id: String,
before_lines: Option<usize>,
after_lines: Option<usize>,
}
#[derive(Debug, Deserialize)]
struct ResolveArgs {
id: String,
}
#[derive(Debug, Clone, Deserialize)]
struct PatchOperationInput {
artifact_id: Option<String>,
action: String,
#[serde(default)]
args: Value,
}
#[derive(Debug, Deserialize)]
struct RecordPatchArgs {
operations: Vec<PatchOperationInput>,
}
#[derive(Debug, Deserialize)]
struct ApplyPatchArgs {
operations: Option<Vec<PatchOperationInput>>,
patch: Option<PresentationPatch>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct PresentationPatch {
version: u32,
artifact_id: String,
operations: Vec<PatchOperation>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct PatchOperation {
action: String,
#[serde(default)]
args: Value,
}
#[derive(Debug, Default, Deserialize)]
struct InsertSlideArgs {
index: Option<u32>,
after_slide_index: Option<u32>,
layout: Option<String>,
notes: Option<String>,
background_fill: Option<String>,
}
#[derive(Debug, Deserialize)]
struct SlideIndexArgs {
slide_index: u32,
}
#[derive(Debug, Deserialize)]
struct MoveSlideArgs {
from_index: u32,
to_index: u32,
}
#[derive(Debug, Deserialize)]
struct SetActiveSlideArgs {
slide_index: u32,
}
#[derive(Debug, Deserialize)]
struct SetSlideBackgroundArgs {
slide_index: u32,
fill: String,
}
#[derive(Debug, Clone, Deserialize)]
struct PositionArgs {
left: u32,
top: u32,
width: u32,
height: u32,
rotation: Option<i32>,
flip_horizontal: Option<bool>,
flip_vertical: Option<bool>,
}
#[derive(Debug, Clone, Default, Deserialize)]
struct PartialPositionArgs {
left: Option<u32>,
top: Option<u32>,
width: Option<u32>,
height: Option<u32>,
rotation: Option<i32>,
flip_horizontal: Option<bool>,
flip_vertical: Option<bool>,
}
#[derive(Debug, Clone, Default, Deserialize)]
struct TextStylingArgs {
style: Option<String>,
font_size: Option<u32>,
font_family: Option<String>,
color: Option<String>,
fill: Option<String>,
alignment: Option<String>,
bold: Option<bool>,
italic: Option<bool>,
underline: Option<bool>,
}
#[derive(Debug, Clone, Default, Deserialize)]
struct TextLayoutArgs {
insets: Option<TextInsetsArgs>,
wrap: Option<String>,
auto_fit: Option<String>,
vertical_alignment: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
struct TextInsetsArgs {
left: u32,
right: u32,
top: u32,
bottom: u32,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(untagged)]
enum RichTextInput {
Plain(String),
Paragraphs(Vec<RichParagraphInput>),
}
#[derive(Debug, Clone, Deserialize)]
#[serde(untagged)]
enum RichParagraphInput {
Plain(String),
Runs(Vec<RichRunInput>),
}
#[derive(Debug, Clone, Deserialize)]
#[serde(untagged)]
enum RichRunInput {
Plain(String),
Styled(RichRunObjectInput),
}
#[derive(Debug, Clone, Deserialize)]
struct RichRunObjectInput {
run: String,
#[serde(default)]
text_style: TextStylingArgs,
link: Option<RichTextLinkInput>,
}
#[derive(Debug, Clone, Deserialize)]
struct RichTextLinkInput {
uri: Option<String>,
is_external: Option<bool>,
}
#[derive(Debug, Deserialize)]
struct AddTextShapeArgs {
slide_index: u32,
text: String,
position: PositionArgs,
#[serde(flatten)]
styling: TextStylingArgs,
#[serde(default)]
text_layout: TextLayoutArgs,
}
#[derive(Debug, Clone, Default, Deserialize)]
struct StrokeArgs {
color: String,
width: u32,
style: Option<String>,
}
#[derive(Debug, Deserialize)]
struct AddShapeArgs {
slide_index: u32,
geometry: String,
position: PositionArgs,
fill: Option<String>,
stroke: Option<StrokeArgs>,
text: Option<String>,
rotation: Option<i32>,
flip_horizontal: Option<bool>,
flip_vertical: Option<bool>,
#[serde(default)]
text_style: TextStylingArgs,
#[serde(default)]
text_layout: TextLayoutArgs,
}
#[derive(Debug, Clone, Default, Deserialize)]
struct ConnectorLineArgs {
color: Option<String>,
width: Option<u32>,
style: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct PointArgs {
left: u32,
top: u32,
}
#[derive(Debug, Deserialize)]
struct AddConnectorArgs {
slide_index: u32,
connector_type: String,
start: PointArgs,
end: PointArgs,
line: Option<ConnectorLineArgs>,
start_arrow: Option<String>,
end_arrow: Option<String>,
arrow_size: Option<String>,
label: Option<String>,
}
#[derive(Debug, Deserialize)]
struct AddImageArgs {
slide_index: u32,
path: Option<PathBuf>,
data_url: Option<String>,
blob: Option<String>,
uri: Option<String>,
position: PositionArgs,
fit: Option<ImageFitMode>,
crop: Option<ImageCropArgs>,
rotation: Option<i32>,
flip_horizontal: Option<bool>,
flip_vertical: Option<bool>,
lock_aspect_ratio: Option<bool>,
alt: Option<String>,
prompt: Option<String>,
}
impl AddImageArgs {
fn image_source(&self) -> Result<ImageInputSource, PresentationArtifactError> {
match (&self.path, &self.data_url, &self.blob, &self.uri) {
(Some(path), None, None, None) => Ok(ImageInputSource::Path(path.clone())),
(None, Some(data_url), None, None) => Ok(ImageInputSource::DataUrl(data_url.clone())),
(None, None, Some(blob), None) => Ok(ImageInputSource::Blob(blob.clone())),
(None, None, None, Some(uri)) => Ok(ImageInputSource::Uri(uri.clone())),
(None, None, None, None) if self.prompt.is_some() => Ok(ImageInputSource::Placeholder),
_ => Err(PresentationArtifactError::InvalidArgs {
action: "add_image".to_string(),
message:
"provide exactly one of `path`, `data_url`, `blob`, or `uri`, or provide `prompt` for a placeholder image"
.to_string(),
}),
}
}
}
enum ImageInputSource {
Path(PathBuf),
DataUrl(String),
Blob(String),
Uri(String),
Placeholder,
}
#[derive(Debug, Clone, Deserialize)]
struct ImageCropArgs {
left: f64,
top: f64,
right: f64,
bottom: f64,
}
#[derive(Debug, Deserialize)]
struct AddTableArgs {
slide_index: u32,
position: PositionArgs,
rows: Vec<Vec<Value>>,
column_widths: Option<Vec<u32>>,
row_heights: Option<Vec<u32>>,
style: Option<String>,
style_options: Option<TableStyleOptionsArgs>,
borders: Option<TableBordersArgs>,
right_to_left: Option<bool>,
}
#[derive(Debug, Deserialize)]
struct AddChartArgs {
slide_index: u32,
position: PositionArgs,
chart_type: String,
categories: Vec<String>,
series: Vec<ChartSeriesArgs>,
title: Option<String>,
style_index: Option<u32>,
has_legend: Option<bool>,
legend_position: Option<String>,
#[serde(default)]
legend_text_style: TextStylingArgs,
x_axis_title: Option<String>,
y_axis_title: Option<String>,
data_labels: Option<ChartDataLabelsArgs>,
chart_fill: Option<String>,
plot_area_fill: Option<String>,
}
#[derive(Debug, Deserialize)]
struct ChartSeriesArgs {
name: String,
values: Vec<f64>,
categories: Option<Vec<String>>,
x_values: Option<Vec<f64>>,
fill: Option<String>,
stroke: Option<StrokeArgs>,
marker: Option<ChartMarkerArgs>,
data_label_overrides: Option<Vec<ChartDataLabelOverrideArgs>>,
}
#[derive(Debug, Clone, Deserialize)]
struct ChartMarkerArgs {
symbol: Option<String>,
size: Option<u32>,
}
#[derive(Debug, Clone, Deserialize)]
struct ChartDataLabelsArgs {
show_value: Option<bool>,
show_category_name: Option<bool>,
show_leader_lines: Option<bool>,
position: Option<String>,
#[serde(default)]
text_style: TextStylingArgs,
}
#[derive(Debug, Clone, Deserialize)]
struct ChartDataLabelOverrideArgs {
idx: u32,
text: Option<String>,
position: Option<String>,
#[serde(default)]
text_style: TextStylingArgs,
fill: Option<String>,
stroke: Option<StrokeArgs>,
}
#[derive(Debug, Deserialize)]
struct UpdateTextArgs {
element_id: String,
text: String,
#[serde(default)]
styling: TextStylingArgs,
#[serde(default)]
text_layout: TextLayoutArgs,
}
#[derive(Debug, Deserialize)]
struct SetRichTextArgs {
element_id: Option<String>,
slide_index: Option<u32>,
row: Option<u32>,
column: Option<u32>,
notes: Option<bool>,
text: RichTextInput,
#[serde(default)]
styling: TextStylingArgs,
#[serde(default)]
text_layout: TextLayoutArgs,
}
#[derive(Debug, Deserialize)]
struct FormatTextRangeArgs {
element_id: Option<String>,
slide_index: Option<u32>,
row: Option<u32>,
column: Option<u32>,
notes: Option<bool>,
query: Option<String>,
occurrence: Option<usize>,
start_cp: Option<usize>,
length: Option<usize>,
#[serde(default)]
styling: TextStylingArgs,
#[serde(default)]
text_layout: TextLayoutArgs,
link: Option<RichTextLinkInput>,
spacing_before: Option<u32>,
spacing_after: Option<u32>,
line_spacing: Option<f32>,
}
#[derive(Debug, Deserialize)]
struct ReplaceTextArgs {
element_id: String,
search: String,
replace: String,
}
#[derive(Debug, Deserialize)]
struct InsertTextAfterArgs {
element_id: String,
after: String,
insert: String,
}
#[derive(Debug, Deserialize)]
struct SetHyperlinkArgs {
element_id: String,
link_type: Option<String>,
url: Option<String>,
slide_index: Option<u32>,
address: Option<String>,
subject: Option<String>,
path: Option<String>,
tooltip: Option<String>,
highlight_click: Option<bool>,
clear: Option<bool>,
}
#[derive(Debug, Deserialize)]
struct UpdateShapeStyleArgs {
element_id: String,
position: Option<PartialPositionArgs>,
fill: Option<String>,
stroke: Option<StrokeArgs>,
rotation: Option<i32>,
flip_horizontal: Option<bool>,
flip_vertical: Option<bool>,
fit: Option<ImageFitMode>,
crop: Option<ImageCropArgs>,
lock_aspect_ratio: Option<bool>,
z_order: Option<u32>,
#[serde(default)]
text_layout: TextLayoutArgs,
}
#[derive(Debug, Deserialize)]
struct ElementIdArgs {
element_id: String,
}
#[derive(Debug, Deserialize)]
struct ReplaceImageArgs {
element_id: String,
path: Option<PathBuf>,
data_url: Option<String>,
blob: Option<String>,
uri: Option<String>,
fit: Option<ImageFitMode>,
crop: Option<ImageCropArgs>,
rotation: Option<i32>,
flip_horizontal: Option<bool>,
flip_vertical: Option<bool>,
lock_aspect_ratio: Option<bool>,
alt: Option<String>,
prompt: Option<String>,
}
#[derive(Debug, Deserialize)]
struct UpdateTableCellArgs {
element_id: String,
row: u32,
column: u32,
value: Value,
#[serde(default)]
styling: TextStylingArgs,
background_fill: Option<String>,
alignment: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
struct TableStyleOptionsArgs {
header_row: Option<bool>,
banded_rows: Option<bool>,
banded_columns: Option<bool>,
first_column: Option<bool>,
last_column: Option<bool>,
total_row: Option<bool>,
}
#[derive(Debug, Clone, Deserialize)]
struct TableBorderArgs {
color: String,
width: u32,
}
#[derive(Debug, Clone, Deserialize)]
struct TableBordersArgs {
outside: Option<TableBorderArgs>,
inside: Option<TableBorderArgs>,
top: Option<TableBorderArgs>,
bottom: Option<TableBorderArgs>,
left: Option<TableBorderArgs>,
right: Option<TableBorderArgs>,
}
#[derive(Debug, Deserialize)]
struct UpdateTableStyleArgs {
element_id: String,
style: Option<String>,
style_options: Option<TableStyleOptionsArgs>,
borders: Option<TableBordersArgs>,
right_to_left: Option<bool>,
}
#[derive(Debug, Deserialize)]
struct StyleTableBlockArgs {
element_id: String,
row: u32,
column: u32,
row_count: u32,
column_count: u32,
#[serde(default)]
styling: TextStylingArgs,
background_fill: Option<String>,
alignment: Option<String>,
borders: Option<TableBordersArgs>,
}
#[derive(Debug, Deserialize)]
struct MergeTableCellsArgs {
element_id: String,
start_row: u32,
end_row: u32,
start_column: u32,
end_column: u32,
}
#[derive(Debug, Deserialize)]
struct UpdateChartArgs {
element_id: String,
title: Option<String>,
categories: Option<Vec<String>>,
style_index: Option<u32>,
has_legend: Option<bool>,
legend_position: Option<String>,
#[serde(default)]
legend_text_style: TextStylingArgs,
x_axis_title: Option<String>,
y_axis_title: Option<String>,
data_labels: Option<ChartDataLabelsArgs>,
chart_fill: Option<String>,
plot_area_fill: Option<String>,
}
#[derive(Debug, Deserialize)]
struct AddChartSeriesArgs {
element_id: String,
name: String,
values: Vec<f64>,
categories: Option<Vec<String>>,
x_values: Option<Vec<f64>>,
fill: Option<String>,
stroke: Option<StrokeArgs>,
marker: Option<ChartMarkerArgs>,
}
#[derive(Debug, Deserialize)]
struct SetCommentAuthorArgs {
display_name: String,
initials: String,
email: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
struct CommentPositionArgs {
x: u32,
y: u32,
}
#[derive(Debug, Deserialize)]
struct AddCommentThreadArgs {
slide_index: Option<u32>,
element_id: Option<String>,
query: Option<String>,
occurrence: Option<usize>,
start_cp: Option<usize>,
length: Option<usize>,
text: String,
position: Option<CommentPositionArgs>,
}
#[derive(Debug, Deserialize)]
struct AddCommentReplyArgs {
thread_id: String,
text: String,
}
#[derive(Debug, Deserialize)]
struct ToggleCommentReactionArgs {
thread_id: String,
message_id: Option<String>,
emoji: String,
}
#[derive(Debug, Deserialize)]
struct CommentThreadIdArgs {
thread_id: String,
}

View File

@@ -1,871 +0,0 @@
fn inspect_document(document: &PresentationDocument, args: &InspectArgs) -> String {
let include_kinds = args
.include
.as_deref()
.or(args.kind.as_deref())
.unwrap_or(
"deck,slide,textbox,shape,connector,table,chart,image,notes,layoutList,textRange,comment",
);
let included_kinds = include_kinds
.split(',')
.map(str::trim)
.filter(|entry| !entry.is_empty())
.collect::<HashSet<_>>();
let excluded_kinds = args
.exclude
.as_deref()
.unwrap_or_default()
.split(',')
.map(str::trim)
.filter(|entry| !entry.is_empty())
.collect::<HashSet<_>>();
let include = |name: &str| included_kinds.contains(name) && !excluded_kinds.contains(name);
let mut records: Vec<(Value, Option<String>)> = Vec::new();
if include("deck") {
records.push((
serde_json::json!({
"kind": "deck",
"id": format!("pr/{}", document.artifact_id),
"name": document.name,
"slides": document.slides.len(),
"styleIds": document
.named_text_styles()
.iter()
.map(|style| format!("st/{}", style.name))
.collect::<Vec<_>>(),
"activeSlideIndex": document.active_slide_index,
"activeSlideId": document.active_slide_index.and_then(|index| document.slides.get(index)).map(|slide| format!("sl/{}", slide.slide_id)),
"commentThreadIds": document
.comment_threads
.iter()
.map(|thread| format!("th/{}", thread.thread_id))
.collect::<Vec<_>>(),
}),
None,
));
}
if include("styleList") {
for style in document.named_text_styles() {
records.push((named_text_style_to_json(&style, "st"), None));
}
}
if include("layoutList") {
for layout in &document.layouts {
let placeholders = resolved_layout_placeholders(document, &layout.layout_id, "inspect")
.unwrap_or_default()
.into_iter()
.map(|placeholder| {
serde_json::json!({
"name": placeholder.definition.name,
"type": placeholder.definition.placeholder_type,
"sourceLayoutId": placeholder.source_layout_id,
"textPreview": placeholder.definition.text,
})
})
.collect::<Vec<_>>();
records.push((
serde_json::json!({
"kind": "layout",
"id": format!("ly/{}", layout.layout_id),
"layoutId": layout.layout_id,
"name": layout.name,
"type": match layout.kind { LayoutKind::Layout => "layout", LayoutKind::Master => "master" },
"parentLayoutId": layout.parent_layout_id,
"placeholders": placeholders,
}),
None,
));
}
}
for (index, slide) in document.slides.iter().enumerate() {
let slide_id = format!("sl/{}", slide.slide_id);
if include("slide") {
records.push((
serde_json::json!({
"kind": "slide",
"id": slide_id,
"slide": index + 1,
"slideIndex": index,
"isActive": document.active_slide_index == Some(index),
"layoutId": slide.layout_id,
"elements": slide.elements.len(),
}),
Some(slide_id.clone()),
));
}
if include("notes") && !slide.notes.text.is_empty() {
records.push((
serde_json::json!({
"kind": "notes",
"id": format!("nt/{}", slide.slide_id),
"slide": index + 1,
"visible": slide.notes.visible,
"text": slide.notes.text,
"textPreview": slide.notes.text.replace('\n', " | "),
"textChars": slide.notes.text.chars().count(),
"textLines": slide.notes.text.lines().count(),
"richText": rich_text_to_proto(&slide.notes.text, &slide.notes.rich_text),
}),
Some(slide_id.clone()),
));
}
if include("textRange") {
records.extend(
slide
.notes
.rich_text
.ranges
.iter()
.map(|range| {
let mut record = text_range_to_proto(&slide.notes.text, range);
record["kind"] = Value::String("textRange".to_string());
record["slide"] = Value::from(index + 1);
record["slideIndex"] = Value::from(index);
record["hostAnchor"] = Value::String(format!("nt/{}", slide.slide_id));
record["hostKind"] = Value::String("notes".to_string());
(record, Some(slide_id.clone()))
}),
);
}
for element in &slide.elements {
let mut record = match element {
PresentationElement::Text(text) => {
if !include("textbox") {
continue;
}
serde_json::json!({
"kind": "textbox",
"id": format!("sh/{}", text.element_id),
"slide": index + 1,
"text": text.text,
"textStyle": text_style_to_proto(&text.style),
"textPreview": text.text.replace('\n', " | "),
"textChars": text.text.chars().count(),
"textLines": text.text.lines().count(),
"richText": rich_text_to_proto(&text.text, &text.rich_text),
"bbox": [text.frame.left, text.frame.top, text.frame.width, text.frame.height],
"bboxUnit": "points",
})
}
PresentationElement::Shape(shape) => {
if !(include("shape") || include("textbox") && shape.text.is_some()) {
continue;
}
let kind = if shape.text.is_some() && include("textbox") {
"textbox"
} else {
"shape"
};
let mut record = serde_json::json!({
"kind": kind,
"id": format!("sh/{}", shape.element_id),
"slide": index + 1,
"geometry": format!("{:?}", shape.geometry),
"text": shape.text,
"textStyle": text_style_to_proto(&shape.text_style),
"richText": shape
.text
.as_ref()
.zip(shape.rich_text.as_ref())
.map(|(text, rich_text)| rich_text_to_proto(text, rich_text))
.unwrap_or(Value::Null),
"rotation": shape.rotation_degrees,
"flipHorizontal": shape.flip_horizontal,
"flipVertical": shape.flip_vertical,
"bbox": [shape.frame.left, shape.frame.top, shape.frame.width, shape.frame.height],
"bboxUnit": "points",
});
if let Some(text) = &shape.text {
record["textPreview"] = Value::String(text.replace('\n', " | "));
record["textChars"] = Value::from(text.chars().count());
record["textLines"] = Value::from(text.lines().count());
}
record
}
PresentationElement::Connector(connector) => {
if !include("shape") && !include("connector") {
continue;
}
serde_json::json!({
"kind": "connector",
"id": format!("cn/{}", connector.element_id),
"slide": index + 1,
"connectorType": format!("{:?}", connector.connector_type),
"start": [connector.start.left, connector.start.top],
"end": [connector.end.left, connector.end.top],
"lineStyle": format!("{:?}", connector.line_style),
"label": connector.label,
})
}
PresentationElement::Table(table) => {
if !include("table") {
continue;
}
serde_json::json!({
"kind": "table",
"id": format!("tb/{}", table.element_id),
"slide": index + 1,
"rows": table.rows.len(),
"cols": table.rows.iter().map(std::vec::Vec::len).max().unwrap_or(0),
"columnWidths": table.column_widths,
"rowHeights": table.row_heights,
"preview": table.rows.first().map(|row| row.iter().map(|cell| cell.text.clone()).collect::<Vec<_>>().join(" | ")),
"style": table.style,
"styleOptions": table_style_options_to_proto(&table.style_options),
"borders": table.borders.as_ref().map(table_borders_to_proto),
"rightToLeft": table.right_to_left,
"cellTextStyles": table
.rows
.iter()
.map(|row| row.iter().map(|cell| text_style_to_proto(&cell.text_style)).collect::<Vec<_>>())
.collect::<Vec<_>>(),
"rowsData": table
.rows
.iter()
.map(|row| row.iter().map(table_cell_to_proto).collect::<Vec<_>>())
.collect::<Vec<_>>(),
"bbox": [table.frame.left, table.frame.top, table.frame.width, table.frame.height],
"bboxUnit": "points",
})
}
PresentationElement::Chart(chart) => {
if !include("chart") {
continue;
}
serde_json::json!({
"kind": "chart",
"id": format!("ch/{}", chart.element_id),
"slide": index + 1,
"chartType": format!("{:?}", chart.chart_type),
"title": chart.title,
"styleIndex": chart.style_index,
"hasLegend": chart.has_legend,
"legend": chart.legend.as_ref().map(chart_legend_to_proto),
"xAxis": chart.x_axis.as_ref().map(chart_axis_to_proto),
"yAxis": chart.y_axis.as_ref().map(chart_axis_to_proto),
"dataLabels": chart.data_labels.as_ref().map(chart_data_labels_to_proto),
"chartFill": chart.chart_fill,
"plotAreaFill": chart.plot_area_fill,
"series": chart
.series
.iter()
.map(|series| serde_json::json!({
"name": series.name,
"values": series.values,
"categories": series.categories,
"xValues": series.x_values,
"fill": series.fill,
"stroke": series.stroke.as_ref().map(stroke_to_proto),
"marker": series.marker.as_ref().map(chart_marker_to_proto),
"dataLabelOverrides": series
.data_label_overrides
.iter()
.map(chart_data_label_override_to_proto)
.collect::<Vec<_>>(),
}))
.collect::<Vec<_>>(),
"bbox": [chart.frame.left, chart.frame.top, chart.frame.width, chart.frame.height],
"bboxUnit": "points",
})
}
PresentationElement::Image(image) => {
if !include("image") {
continue;
}
serde_json::json!({
"kind": "image",
"id": format!("im/{}", image.element_id),
"slide": index + 1,
"alt": image.alt_text,
"prompt": image.prompt,
"fit": format!("{:?}", image.fit_mode),
"rotation": image.rotation_degrees,
"flipHorizontal": image.flip_horizontal,
"flipVertical": image.flip_vertical,
"crop": image.crop.map(|(left, top, right, bottom)| serde_json::json!({
"left": left,
"top": top,
"right": right,
"bottom": bottom,
})),
"isPlaceholder": image.is_placeholder,
"lockAspectRatio": image.lock_aspect_ratio,
"bbox": [image.frame.left, image.frame.top, image.frame.width, image.frame.height],
"bboxUnit": "points",
})
}
};
if let Some(placeholder) = match element {
PresentationElement::Text(text) => text.placeholder.as_ref(),
PresentationElement::Shape(shape) => shape.placeholder.as_ref(),
PresentationElement::Connector(_)
| PresentationElement::Table(_)
| PresentationElement::Chart(_) => None,
PresentationElement::Image(image) => image.placeholder.as_ref(),
} {
record["placeholder"] = Value::String(placeholder.placeholder_type.clone());
record["placeholderName"] = Value::String(placeholder.name.clone());
record["placeholderIndex"] =
placeholder.index.map(Value::from).unwrap_or(Value::Null);
}
if let PresentationElement::Shape(shape) = element
&& let Some(stroke) = &shape.stroke
{
record["stroke"] = serde_json::json!({
"color": stroke.color,
"width": stroke.width,
"style": stroke.style.as_api_str(),
});
}
if let Some(hyperlink) = match element {
PresentationElement::Text(text) => text.hyperlink.as_ref(),
PresentationElement::Shape(shape) => shape.hyperlink.as_ref(),
PresentationElement::Connector(_)
| PresentationElement::Image(_)
| PresentationElement::Table(_)
| PresentationElement::Chart(_) => None,
} {
record["hyperlink"] = hyperlink.to_json();
}
records.push((record, Some(slide_id.clone())));
if include("textRange") {
match element {
PresentationElement::Text(text) => {
records.extend(text.rich_text.ranges.iter().map(|range| {
let mut record = text_range_to_proto(&text.text, range);
record["kind"] = Value::String("textRange".to_string());
record["slide"] = Value::from(index + 1);
record["slideIndex"] = Value::from(index);
record["hostAnchor"] = Value::String(format!("sh/{}", text.element_id));
record["hostKind"] = Value::String("textbox".to_string());
(record, Some(slide_id.clone()))
}));
}
PresentationElement::Shape(shape) => {
if let Some((text, rich_text)) = shape.text.as_ref().zip(shape.rich_text.as_ref()) {
records.extend(rich_text.ranges.iter().map(|range| {
let mut record = text_range_to_proto(text, range);
record["kind"] = Value::String("textRange".to_string());
record["slide"] = Value::from(index + 1);
record["slideIndex"] = Value::from(index);
record["hostAnchor"] = Value::String(format!("sh/{}", shape.element_id));
record["hostKind"] = Value::String("textbox".to_string());
(record, Some(slide_id.clone()))
}));
}
}
PresentationElement::Table(table) => {
for (row_index, row) in table.rows.iter().enumerate() {
for (column_index, cell) in row.iter().enumerate() {
records.extend(cell.rich_text.ranges.iter().map(|range| {
let mut record = text_range_to_proto(&cell.text, range);
record["kind"] = Value::String("textRange".to_string());
record["slide"] = Value::from(index + 1);
record["slideIndex"] = Value::from(index);
record["hostAnchor"] = Value::String(format!(
"tb/{}#cell/{row_index}/{column_index}",
table.element_id
));
record["hostKind"] = Value::String("tableCell".to_string());
(record, Some(slide_id.clone()))
}));
}
}
}
PresentationElement::Connector(_)
| PresentationElement::Image(_)
| PresentationElement::Chart(_) => {}
}
}
}
}
if include("comment") {
records.extend(document.comment_threads.iter().map(|thread| {
let mut record = comment_thread_to_proto(thread);
record["id"] = Value::String(format!("th/{}", thread.thread_id));
(record, None)
}));
}
if let Some(target_id) = args.target_id.as_deref() {
records.retain(|(record, slide_id)| {
legacy_target_matches(target_id, record, slide_id.as_deref())
});
if records.is_empty() {
records.push((
serde_json::json!({
"kind": "notice",
"noticeType": "targetNotFound",
"target": { "id": target_id },
"message": format!("No inspect records matched target `{target_id}`."),
}),
None,
));
}
}
if let Some(search) = args.search.as_deref() {
let search_lowercase = search.to_ascii_lowercase();
records.retain(|(record, _)| {
record
.to_string()
.to_ascii_lowercase()
.contains(&search_lowercase)
});
if records.is_empty() {
records.push((
serde_json::json!({
"kind": "notice",
"noticeType": "noMatches",
"search": search,
"message": format!("No inspect records matched search `{search}`."),
}),
None,
));
}
}
if let Some(target) = args.target.as_ref() {
if let Some(target_index) = records.iter().position(|(record, _)| {
record.get("id").and_then(Value::as_str) == Some(target.id.as_str())
}) {
let start = target_index.saturating_sub(target.before_lines.unwrap_or(0));
let end = (target_index + target.after_lines.unwrap_or(0) + 1).min(records.len());
records = records.into_iter().skip(start).take(end - start).collect();
} else {
records = vec![(
serde_json::json!({
"kind": "notice",
"noticeType": "targetNotFound",
"target": {
"id": target.id,
"beforeLines": target.before_lines,
"afterLines": target.after_lines,
},
"message": format!("No inspect records matched target `{}`.", target.id),
}),
None,
)];
}
}
let mut lines = Vec::new();
let mut omitted_lines = 0usize;
let mut omitted_chars = 0usize;
for line in records.into_iter().map(|(record, _)| record.to_string()) {
let separator_len = usize::from(!lines.is_empty());
if let Some(max_chars) = args.max_chars
&& lines.iter().map(String::len).sum::<usize>() + separator_len + line.len() > max_chars
{
omitted_lines += 1;
omitted_chars += line.len();
continue;
}
lines.push(line);
}
if omitted_lines > 0 {
lines.push(
serde_json::json!({
"kind": "notice",
"noticeType": "truncation",
"maxChars": args.max_chars,
"omittedLines": omitted_lines,
"omittedChars": omitted_chars,
"message": format!(
"Truncated inspect output by omitting {omitted_lines} lines. Increase maxChars or narrow the filter."
),
})
.to_string(),
);
}
lines.join("\n")
}
fn legacy_target_matches(target_id: &str, record: &Value, slide_id: Option<&str>) -> bool {
record.get("id").and_then(Value::as_str) == Some(target_id) || slide_id == Some(target_id)
}
fn add_text_metadata(record: &mut Value, text: &str) {
record["textPreview"] = Value::String(text.replace('\n', " | "));
record["textChars"] = Value::from(text.chars().count());
record["textLines"] = Value::from(text.lines().count());
}
fn normalize_element_lookup_id(element_id: &str) -> &str {
element_id
.split_once('/')
.map(|(_, normalized)| normalized)
.unwrap_or(element_id)
}
fn resolve_anchor(
document: &PresentationDocument,
id: &str,
action: &str,
) -> Result<Value, PresentationArtifactError> {
if id == format!("pr/{}", document.artifact_id) {
return Ok(serde_json::json!({
"kind": "deck",
"id": id,
"artifactId": document.artifact_id,
"name": document.name,
"slideCount": document.slides.len(),
"styleIds": document
.named_text_styles()
.iter()
.map(|style| format!("st/{}", style.name))
.collect::<Vec<_>>(),
"activeSlideIndex": document.active_slide_index,
"activeSlideId": document.active_slide_index.and_then(|index| document.slides.get(index)).map(|slide| format!("sl/{}", slide.slide_id)),
}));
}
if let Some(style_name) = id.strip_prefix("st/") {
let named_style = document
.named_text_styles()
.into_iter()
.find(|style| style.name == style_name)
.ok_or_else(|| PresentationArtifactError::UnsupportedFeature {
action: action.to_string(),
message: format!("unknown style id `{id}`"),
})?;
return Ok(named_text_style_to_json(&named_style, "st"));
}
for (slide_index, slide) in document.slides.iter().enumerate() {
let slide_id = format!("sl/{}", slide.slide_id);
if id == slide_id {
return Ok(serde_json::json!({
"kind": "slide",
"id": slide_id,
"slide": slide_index + 1,
"slideIndex": slide_index,
"isActive": document.active_slide_index == Some(slide_index),
"layoutId": slide.layout_id,
"notesId": (!slide.notes.text.is_empty()).then(|| format!("nt/{}", slide.slide_id)),
"elementIds": slide.elements.iter().map(|element| {
let prefix = match element {
PresentationElement::Text(_) | PresentationElement::Shape(_) => "sh",
PresentationElement::Connector(_) => "cn",
PresentationElement::Image(_) => "im",
PresentationElement::Table(_) => "tb",
PresentationElement::Chart(_) => "ch",
};
format!("{prefix}/{}", element.element_id())
}).collect::<Vec<_>>(),
}));
}
let notes_id = format!("nt/{}", slide.slide_id);
if id == notes_id {
let mut record = serde_json::json!({
"kind": "notes",
"id": notes_id,
"slide": slide_index + 1,
"slideIndex": slide_index,
"visible": slide.notes.visible,
"text": slide.notes.text,
});
add_text_metadata(&mut record, &slide.notes.text);
record["richText"] = rich_text_to_proto(&slide.notes.text, &slide.notes.rich_text);
return Ok(record);
}
if let Some(range_id) = id.strip_prefix("tr/")
&& let Some(record) = slide
.notes
.rich_text
.ranges
.iter()
.find(|range| range.range_id == range_id)
.map(|range| {
let mut record = text_range_to_proto(&slide.notes.text, range);
record["kind"] = Value::String("textRange".to_string());
record["id"] = Value::String(id.to_string());
record["slide"] = Value::from(slide_index + 1);
record["slideIndex"] = Value::from(slide_index);
record["hostAnchor"] = Value::String(notes_id.clone());
record["hostKind"] = Value::String("notes".to_string());
record
})
{
return Ok(record);
}
for element in &slide.elements {
let mut record = match element {
PresentationElement::Text(text) => {
let mut record = serde_json::json!({
"kind": "textbox",
"id": format!("sh/{}", text.element_id),
"elementId": text.element_id,
"slide": slide_index + 1,
"slideIndex": slide_index,
"text": text.text,
"textStyle": text_style_to_proto(&text.style),
"richText": rich_text_to_proto(&text.text, &text.rich_text),
"bbox": [text.frame.left, text.frame.top, text.frame.width, text.frame.height],
"bboxUnit": "points",
});
add_text_metadata(&mut record, &text.text);
record
}
PresentationElement::Shape(shape) => {
let mut record = serde_json::json!({
"kind": if shape.text.is_some() { "textbox" } else { "shape" },
"id": format!("sh/{}", shape.element_id),
"elementId": shape.element_id,
"slide": slide_index + 1,
"slideIndex": slide_index,
"geometry": format!("{:?}", shape.geometry),
"text": shape.text,
"textStyle": text_style_to_proto(&shape.text_style),
"richText": shape
.text
.as_ref()
.zip(shape.rich_text.as_ref())
.map(|(text, rich_text)| rich_text_to_proto(text, rich_text))
.unwrap_or(Value::Null),
"rotation": shape.rotation_degrees,
"flipHorizontal": shape.flip_horizontal,
"flipVertical": shape.flip_vertical,
"bbox": [shape.frame.left, shape.frame.top, shape.frame.width, shape.frame.height],
"bboxUnit": "points",
});
if let Some(text) = &shape.text {
add_text_metadata(&mut record, text);
}
record
}
PresentationElement::Connector(connector) => serde_json::json!({
"kind": "connector",
"id": format!("cn/{}", connector.element_id),
"elementId": connector.element_id,
"slide": slide_index + 1,
"slideIndex": slide_index,
"connectorType": format!("{:?}", connector.connector_type),
"start": [connector.start.left, connector.start.top],
"end": [connector.end.left, connector.end.top],
"lineStyle": format!("{:?}", connector.line_style),
"label": connector.label,
}),
PresentationElement::Image(image) => serde_json::json!({
"kind": "image",
"id": format!("im/{}", image.element_id),
"elementId": image.element_id,
"slide": slide_index + 1,
"slideIndex": slide_index,
"alt": image.alt_text,
"prompt": image.prompt,
"fit": format!("{:?}", image.fit_mode),
"rotation": image.rotation_degrees,
"flipHorizontal": image.flip_horizontal,
"flipVertical": image.flip_vertical,
"crop": image.crop.map(|(left, top, right, bottom)| serde_json::json!({
"left": left,
"top": top,
"right": right,
"bottom": bottom,
})),
"isPlaceholder": image.is_placeholder,
"lockAspectRatio": image.lock_aspect_ratio,
"bbox": [image.frame.left, image.frame.top, image.frame.width, image.frame.height],
"bboxUnit": "points",
}),
PresentationElement::Table(table) => serde_json::json!({
"kind": "table",
"id": format!("tb/{}", table.element_id),
"elementId": table.element_id,
"slide": slide_index + 1,
"slideIndex": slide_index,
"rows": table.rows.len(),
"cols": table.rows.iter().map(std::vec::Vec::len).max().unwrap_or(0),
"columnWidths": table.column_widths,
"rowHeights": table.row_heights,
"style": table.style,
"styleOptions": table_style_options_to_proto(&table.style_options),
"borders": table.borders.as_ref().map(table_borders_to_proto),
"rightToLeft": table.right_to_left,
"cellTextStyles": table
.rows
.iter()
.map(|row| row.iter().map(|cell| text_style_to_proto(&cell.text_style)).collect::<Vec<_>>())
.collect::<Vec<_>>(),
"rowsData": table
.rows
.iter()
.map(|row| row.iter().map(table_cell_to_proto).collect::<Vec<_>>())
.collect::<Vec<_>>(),
"bbox": [table.frame.left, table.frame.top, table.frame.width, table.frame.height],
"bboxUnit": "points",
}),
PresentationElement::Chart(chart) => serde_json::json!({
"kind": "chart",
"id": format!("ch/{}", chart.element_id),
"elementId": chart.element_id,
"slide": slide_index + 1,
"slideIndex": slide_index,
"chartType": format!("{:?}", chart.chart_type),
"title": chart.title,
"styleIndex": chart.style_index,
"hasLegend": chart.has_legend,
"legend": chart.legend.as_ref().map(chart_legend_to_proto),
"xAxis": chart.x_axis.as_ref().map(chart_axis_to_proto),
"yAxis": chart.y_axis.as_ref().map(chart_axis_to_proto),
"dataLabels": chart.data_labels.as_ref().map(chart_data_labels_to_proto),
"chartFill": chart.chart_fill,
"plotAreaFill": chart.plot_area_fill,
"series": chart
.series
.iter()
.map(|series| serde_json::json!({
"name": series.name,
"values": series.values,
"categories": series.categories,
"xValues": series.x_values,
"fill": series.fill,
"stroke": series.stroke.as_ref().map(stroke_to_proto),
"marker": series.marker.as_ref().map(chart_marker_to_proto),
"dataLabelOverrides": series
.data_label_overrides
.iter()
.map(chart_data_label_override_to_proto)
.collect::<Vec<_>>(),
}))
.collect::<Vec<_>>(),
"bbox": [chart.frame.left, chart.frame.top, chart.frame.width, chart.frame.height],
"bboxUnit": "points",
}),
};
if let Some(hyperlink) = match element {
PresentationElement::Text(text) => text.hyperlink.as_ref(),
PresentationElement::Shape(shape) => shape.hyperlink.as_ref(),
PresentationElement::Connector(_)
| PresentationElement::Image(_)
| PresentationElement::Table(_)
| PresentationElement::Chart(_) => None,
} {
record["hyperlink"] = hyperlink.to_json();
}
if let PresentationElement::Shape(shape) = element
&& let Some(stroke) = &shape.stroke
{
record["stroke"] = serde_json::json!({
"color": stroke.color,
"width": stroke.width,
"style": stroke.style.as_api_str(),
});
}
if let Some(placeholder) = match element {
PresentationElement::Text(text) => text.placeholder.as_ref(),
PresentationElement::Shape(shape) => shape.placeholder.as_ref(),
PresentationElement::Image(image) => image.placeholder.as_ref(),
PresentationElement::Connector(_)
| PresentationElement::Table(_)
| PresentationElement::Chart(_) => None,
} {
record["placeholder"] = Value::String(placeholder.placeholder_type.clone());
record["placeholderName"] = Value::String(placeholder.name.clone());
record["placeholderIndex"] =
placeholder.index.map(Value::from).unwrap_or(Value::Null);
}
if record.get("id").and_then(Value::as_str) == Some(id) {
return Ok(record);
}
if let Some(range_id) = id.strip_prefix("tr/") {
match element {
PresentationElement::Text(text) => {
if let Some(range) =
text.rich_text.ranges.iter().find(|range| range.range_id == range_id)
{
let mut range_record = text_range_to_proto(&text.text, range);
range_record["kind"] = Value::String("textRange".to_string());
range_record["id"] = Value::String(id.to_string());
range_record["slide"] = Value::from(slide_index + 1);
range_record["slideIndex"] = Value::from(slide_index);
range_record["hostAnchor"] =
Value::String(format!("sh/{}", text.element_id));
range_record["hostKind"] = Value::String("textbox".to_string());
return Ok(range_record);
}
}
PresentationElement::Shape(shape) => {
if let Some((text, rich_text)) =
shape.text.as_ref().zip(shape.rich_text.as_ref())
&& let Some(range) =
rich_text.ranges.iter().find(|range| range.range_id == range_id)
{
let mut range_record = text_range_to_proto(text, range);
range_record["kind"] = Value::String("textRange".to_string());
range_record["id"] = Value::String(id.to_string());
range_record["slide"] = Value::from(slide_index + 1);
range_record["slideIndex"] = Value::from(slide_index);
range_record["hostAnchor"] =
Value::String(format!("sh/{}", shape.element_id));
range_record["hostKind"] = Value::String("textbox".to_string());
return Ok(range_record);
}
}
PresentationElement::Table(table) => {
for (row_index, row) in table.rows.iter().enumerate() {
for (column_index, cell) in row.iter().enumerate() {
if let Some(range) = cell
.rich_text
.ranges
.iter()
.find(|range| range.range_id == range_id)
{
let mut range_record = text_range_to_proto(&cell.text, range);
range_record["kind"] = Value::String("textRange".to_string());
range_record["id"] = Value::String(id.to_string());
range_record["slide"] = Value::from(slide_index + 1);
range_record["slideIndex"] = Value::from(slide_index);
range_record["hostAnchor"] = Value::String(format!(
"tb/{}#cell/{row_index}/{column_index}",
table.element_id
));
range_record["hostKind"] =
Value::String("tableCell".to_string());
return Ok(range_record);
}
}
}
}
PresentationElement::Connector(_)
| PresentationElement::Image(_)
| PresentationElement::Chart(_) => {}
}
}
}
}
if let Some(thread_id) = id.strip_prefix("th/")
&& let Some(thread) = document
.comment_threads
.iter()
.find(|thread| thread.thread_id == thread_id)
{
let mut record = comment_thread_to_proto(thread);
record["id"] = Value::String(id.to_string());
return Ok(record);
}
for layout in &document.layouts {
let layout_id = format!("ly/{}", layout.layout_id);
if id == layout_id {
return Ok(serde_json::json!({
"kind": "layout",
"id": layout_id,
"layoutId": layout.layout_id,
"name": layout.name,
"type": match layout.kind {
LayoutKind::Layout => "layout",
LayoutKind::Master => "master",
},
"parentLayoutId": layout.parent_layout_id,
"placeholders": layout_placeholder_list(document, &layout.layout_id, action)?,
}));
}
}
Err(PresentationArtifactError::UnsupportedFeature {
action: action.to_string(),
message: format!("unknown resolve id `{id}`"),
})
}

View File

@@ -1,10 +0,0 @@
include!("api.rs");
include!("manager.rs");
include!("response.rs");
include!("model.rs");
include!("args.rs");
include!("parsing.rs");
include!("proto.rs");
include!("inspect.rs");
include!("pptx.rs");
include!("snapshot.rs");

View File

@@ -1,951 +0,0 @@
const CODEX_METADATA_ENTRY: &str = "ppt/codex-document.json";
fn import_codex_metadata_document(path: &Path) -> Result<Option<PresentationDocument>, String> {
let file = std::fs::File::open(path).map_err(|error| error.to_string())?;
let mut archive = ZipArchive::new(file).map_err(|error| error.to_string())?;
let mut entry = match archive.by_name(CODEX_METADATA_ENTRY) {
Ok(entry) => entry,
Err(zip::result::ZipError::FileNotFound) => return Ok(None),
Err(error) => return Err(error.to_string()),
};
let mut bytes = Vec::new();
entry.read_to_end(&mut bytes)
.map_err(|error| error.to_string())?;
serde_json::from_slice(&bytes)
.map(Some)
.map_err(|error| error.to_string())
}
fn build_pptx_bytes(document: &PresentationDocument, action: &str) -> Result<Vec<u8>, String> {
let bytes = document
.to_ppt_rs()
.build()
.map_err(|error| format!("{action}: {error}"))?;
patch_pptx_package(bytes, document).map_err(|error| format!("{action}: {error}"))
}
struct SlideImageAsset {
xml: String,
relationship_xml: String,
media_path: String,
media_bytes: Vec<u8>,
extension: String,
}
fn normalized_image_extension(format: &str) -> String {
match format.to_ascii_lowercase().as_str() {
"jpeg" => "jpg".to_string(),
other => other.to_string(),
}
}
fn image_relationship_xml(relationship_id: &str, target: &str) -> String {
format!(
r#"<Relationship Id="{relationship_id}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="{}"/>"#,
ppt_rs::escape_xml(target)
)
}
fn image_picture_xml(
image: &ImageElement,
shape_id: usize,
relationship_id: &str,
frame: Rect,
crop: Option<ImageCrop>,
) -> String {
let blip_fill = if let Some((crop_left, crop_top, crop_right, crop_bottom)) = crop {
format!(
r#"<p:blipFill>
<a:blip r:embed="{relationship_id}"/>
<a:srcRect l="{}" t="{}" r="{}" b="{}"/>
<a:stretch>
<a:fillRect/>
</a:stretch>
</p:blipFill>"#,
(crop_left * 100_000.0).round() as u32,
(crop_top * 100_000.0).round() as u32,
(crop_right * 100_000.0).round() as u32,
(crop_bottom * 100_000.0).round() as u32,
)
} else {
format!(
r#"<p:blipFill>
<a:blip r:embed="{relationship_id}"/>
<a:stretch>
<a:fillRect/>
</a:stretch>
</p:blipFill>"#
)
};
let descr = image
.alt_text
.as_deref()
.map(|alt| format!(r#" descr="{}""#, ppt_rs::escape_xml(alt)))
.unwrap_or_default();
let no_change_aspect = if image.lock_aspect_ratio { 1 } else { 0 };
let rotation = image
.rotation_degrees
.map(|rotation| format!(r#" rot="{}""#, i64::from(rotation) * 60_000))
.unwrap_or_default();
let flip_horizontal = if image.flip_horizontal {
r#" flipH="1""#
} else {
""
};
let flip_vertical = if image.flip_vertical {
r#" flipV="1""#
} else {
""
};
format!(
r#"<p:pic>
<p:nvPicPr>
<p:cNvPr id="{shape_id}" name="Picture {shape_id}"{descr}/>
<p:cNvPicPr>
<a:picLocks noChangeAspect="{no_change_aspect}"/>
</p:cNvPicPr>
<p:nvPr/>
</p:nvPicPr>
{blip_fill}
<p:spPr>
<a:xfrm{rotation}{flip_horizontal}{flip_vertical}>
<a:off x="{}" y="{}"/>
<a:ext cx="{}" cy="{}"/>
</a:xfrm>
<a:prstGeom prst="rect">
<a:avLst/>
</a:prstGeom>
</p:spPr>
</p:pic>"#,
points_to_emu(frame.left),
points_to_emu(frame.top),
points_to_emu(frame.width),
points_to_emu(frame.height),
)
}
fn slide_image_assets(
slide: &PresentationSlide,
next_media_index: &mut usize,
) -> Vec<SlideImageAsset> {
let mut ordered = slide.elements.iter().collect::<Vec<_>>();
ordered.sort_by_key(|element| element.z_order());
let shape_count = ordered
.iter()
.filter(|element| {
matches!(
element,
PresentationElement::Text(_)
| PresentationElement::Shape(_)
| PresentationElement::Image(ImageElement { payload: None, .. })
)
})
.count()
+ usize::from(slide.background_fill.is_some());
let mut image_index = 0_usize;
let mut assets = Vec::new();
for element in ordered {
let PresentationElement::Image(image) = element else {
continue;
};
let Some(payload) = &image.payload else {
continue;
};
let (left, top, width, height, fitted_crop) = if image.fit_mode != ImageFitMode::Stretch {
fit_image(image)
} else {
(
image.frame.left,
image.frame.top,
image.frame.width,
image.frame.height,
None,
)
};
image_index += 1;
let relationship_id = format!("rIdImage{image_index}");
let extension = normalized_image_extension(&payload.format);
let media_name = format!("image{next_media_index}.{extension}");
*next_media_index += 1;
assets.push(SlideImageAsset {
xml: image_picture_xml(
image,
20 + shape_count + image_index - 1,
&relationship_id,
Rect {
left,
top,
width,
height,
},
image.crop.or(fitted_crop),
),
relationship_xml: image_relationship_xml(
&relationship_id,
&format!("../media/{media_name}"),
),
media_path: format!("ppt/media/{media_name}"),
media_bytes: payload.bytes.clone(),
extension,
});
}
assets
}
fn patch_pptx_package(
source_bytes: Vec<u8>,
document: &PresentationDocument,
) -> Result<Vec<u8>, String> {
let mut archive =
ZipArchive::new(Cursor::new(source_bytes)).map_err(|error| error.to_string())?;
let mut writer = ZipWriter::new(Cursor::new(Vec::new()));
let mut next_media_index = 1_usize;
let mut pending_slide_relationships = HashMap::new();
let mut pending_slide_images = HashMap::new();
let mut pending_media = Vec::new();
let mut image_extensions = BTreeSet::new();
for (slide_index, slide) in document.slides.iter().enumerate() {
let slide_number = slide_index + 1;
let images = slide_image_assets(slide, &mut next_media_index);
let mut relationships = slide_hyperlink_relationships(slide);
relationships.extend(images.iter().map(|image| image.relationship_xml.clone()));
if !relationships.is_empty() {
pending_slide_relationships.insert(slide_number, relationships);
}
if !images.is_empty() {
image_extensions.extend(images.iter().map(|image| image.extension.clone()));
pending_media.extend(
images
.iter()
.map(|image| (image.media_path.clone(), image.media_bytes.clone())),
);
pending_slide_images.insert(slide_number, images);
}
}
for index in 0..archive.len() {
let mut file = archive.by_index(index).map_err(|error| error.to_string())?;
if file.is_dir() {
continue;
}
let name = file.name().to_string();
if name == CODEX_METADATA_ENTRY {
continue;
}
let options = file.options();
let mut bytes = Vec::new();
file.read_to_end(&mut bytes)
.map_err(|error| error.to_string())?;
writer
.start_file(&name, options)
.map_err(|error| error.to_string())?;
if name == "[Content_Types].xml" {
writer
.write_all(update_content_types_xml(bytes, &image_extensions)?.as_bytes())
.map_err(|error| error.to_string())?;
continue;
}
if name == "ppt/presentation.xml" {
writer
.write_all(
update_presentation_xml_dimensions(bytes, document.slide_size)?.as_bytes(),
)
.map_err(|error| error.to_string())?;
continue;
}
if let Some(slide_number) = parse_slide_xml_path(&name) {
writer
.write_all(
update_slide_xml(
bytes,
&document.slides[slide_number - 1],
pending_slide_images
.get(&slide_number)
.map(std::vec::Vec::as_slice)
.unwrap_or(&[]),
)?
.as_bytes(),
)
.map_err(|error| error.to_string())?;
continue;
}
if let Some(slide_number) = parse_slide_relationships_path(&name)
&& let Some(relationships) = pending_slide_relationships.remove(&slide_number)
{
writer
.write_all(update_slide_relationships_xml(bytes, &relationships)?.as_bytes())
.map_err(|error| error.to_string())?;
continue;
}
writer
.write_all(&bytes)
.map_err(|error| error.to_string())?;
}
for (slide_number, relationships) in pending_slide_relationships {
writer
.start_file(
format!("ppt/slides/_rels/slide{slide_number}.xml.rels"),
SimpleFileOptions::default(),
)
.map_err(|error| error.to_string())?;
writer
.write_all(slide_relationships_xml(&relationships).as_bytes())
.map_err(|error| error.to_string())?;
}
for (path, bytes) in pending_media {
writer
.start_file(path, SimpleFileOptions::default())
.map_err(|error| error.to_string())?;
writer
.write_all(&bytes)
.map_err(|error| error.to_string())?;
}
writer
.start_file(CODEX_METADATA_ENTRY, SimpleFileOptions::default())
.map_err(|error| error.to_string())?;
writer
.write_all(
&serde_json::to_vec(document).map_err(|error| error.to_string())?,
)
.map_err(|error| error.to_string())?;
writer
.finish()
.map_err(|error| error.to_string())
.map(Cursor::into_inner)
}
fn update_presentation_xml_dimensions(
existing_bytes: Vec<u8>,
slide_size: Rect,
) -> Result<String, String> {
let existing = String::from_utf8(existing_bytes).map_err(|error| error.to_string())?;
let updated = replace_self_closing_xml_tag(
&existing,
"p:sldSz",
&format!(
r#"<p:sldSz cx="{}" cy="{}" type="screen4x3"/>"#,
points_to_emu(slide_size.width),
points_to_emu(slide_size.height)
),
)?;
replace_self_closing_xml_tag(
&updated,
"p:notesSz",
&format!(
r#"<p:notesSz cx="{}" cy="{}"/>"#,
points_to_emu(slide_size.height),
points_to_emu(slide_size.width)
),
)
}
fn replace_self_closing_xml_tag(xml: &str, tag: &str, replacement: &str) -> Result<String, String> {
let start = xml
.find(&format!("<{tag} "))
.ok_or_else(|| format!("presentation xml is missing `<{tag} .../>`"))?;
let end = xml[start..]
.find("/>")
.map(|offset| start + offset + 2)
.ok_or_else(|| format!("presentation xml tag `{tag}` is not self-closing"))?;
Ok(format!("{}{replacement}{}", &xml[..start], &xml[end..]))
}
fn slide_hyperlink_relationships(slide: &PresentationSlide) -> Vec<String> {
let mut ordered = slide.elements.iter().collect::<Vec<_>>();
ordered.sort_by_key(|element| element.z_order());
let mut hyperlink_index = 1_u32;
let mut relationships = Vec::new();
for element in ordered {
let Some(hyperlink) = (match element {
PresentationElement::Text(text) => text.hyperlink.as_ref(),
PresentationElement::Shape(shape) => shape.hyperlink.as_ref(),
PresentationElement::Connector(_)
| PresentationElement::Image(_)
| PresentationElement::Table(_)
| PresentationElement::Chart(_) => None,
}) else {
continue;
};
let relationship_id = format!("rIdHyperlink{hyperlink_index}");
hyperlink_index += 1;
relationships.push(hyperlink.relationship_xml(&relationship_id));
}
relationships
}
fn parse_slide_relationships_path(path: &str) -> Option<usize> {
path.strip_prefix("ppt/slides/_rels/slide")?
.strip_suffix(".xml.rels")?
.parse::<usize>()
.ok()
}
fn parse_slide_xml_path(path: &str) -> Option<usize> {
path.strip_prefix("ppt/slides/slide")?
.strip_suffix(".xml")?
.parse::<usize>()
.ok()
}
fn update_slide_relationships_xml(
existing_bytes: Vec<u8>,
relationships: &[String],
) -> Result<String, String> {
let existing = String::from_utf8(existing_bytes).map_err(|error| error.to_string())?;
let injected = relationships.join("\n");
existing
.contains("</Relationships>")
.then(|| existing.replace("</Relationships>", &format!("{injected}\n</Relationships>")))
.ok_or_else(|| {
"slide relationships xml is missing a closing `</Relationships>`".to_string()
})
}
fn slide_relationships_xml(relationships: &[String]) -> String {
let body = relationships.join("\n");
format!(
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
{body}
</Relationships>"#
)
}
fn update_content_types_xml(
existing_bytes: Vec<u8>,
image_extensions: &BTreeSet<String>,
) -> Result<String, String> {
let existing = String::from_utf8(existing_bytes).map_err(|error| error.to_string())?;
if image_extensions.is_empty() {
return Ok(existing);
}
let existing_lower = existing.to_ascii_lowercase();
let additions = image_extensions
.iter()
.filter(|extension| {
!existing_lower.contains(&format!(
r#"extension="{}""#,
extension.to_ascii_lowercase()
))
})
.map(|extension| generate_image_content_type(extension))
.collect::<Vec<_>>();
if additions.is_empty() {
return Ok(existing);
}
existing
.contains("</Types>")
.then(|| existing.replace("</Types>", &format!("{}\n</Types>", additions.join("\n"))))
.ok_or_else(|| "content types xml is missing a closing `</Types>`".to_string())
}
fn update_slide_xml(
existing_bytes: Vec<u8>,
slide: &PresentationSlide,
slide_images: &[SlideImageAsset],
) -> Result<String, String> {
let existing = String::from_utf8(existing_bytes).map_err(|error| error.to_string())?;
let existing = replace_image_placeholders(existing, slide_images)?;
let existing = apply_shape_block_patches(existing, slide)?;
let table_xml = slide_table_xml(slide);
if table_xml.is_empty() {
return Ok(existing);
}
existing
.contains("</p:spTree>")
.then(|| existing.replace("</p:spTree>", &format!("{table_xml}\n</p:spTree>")))
.ok_or_else(|| "slide xml is missing a closing `</p:spTree>`".to_string())
}
fn replace_image_placeholders(
existing: String,
slide_images: &[SlideImageAsset],
) -> Result<String, String> {
if slide_images.is_empty() {
return Ok(existing);
}
let mut updated = String::with_capacity(existing.len());
let mut remaining = existing.as_str();
for image in slide_images {
let marker = remaining
.find("name=\"Image Placeholder: ")
.ok_or_else(|| {
"slide xml is missing an image placeholder block for exported images".to_string()
})?;
let start = remaining[..marker].rfind("<p:sp>").ok_or_else(|| {
"slide xml is missing an opening `<p:sp>` for image placeholder".to_string()
})?;
let end = remaining[marker..]
.find("</p:sp>")
.map(|offset| marker + offset + "</p:sp>".len())
.ok_or_else(|| {
"slide xml is missing a closing `</p:sp>` for image placeholder".to_string()
})?;
updated.push_str(&remaining[..start]);
updated.push_str(&image.xml);
remaining = &remaining[end..];
}
updated.push_str(remaining);
Ok(updated)
}
#[derive(Clone, Copy)]
struct ShapeXmlPatch {
line_style: Option<LineStyle>,
flip_horizontal: bool,
flip_vertical: bool,
}
fn apply_shape_block_patches(
existing: String,
slide: &PresentationSlide,
) -> Result<String, String> {
let mut patches = Vec::new();
if slide.background_fill.is_some() {
patches.push(None);
}
let mut ordered = slide.elements.iter().collect::<Vec<_>>();
ordered.sort_by_key(|element| element.z_order());
for element in ordered {
match element {
PresentationElement::Text(_) => patches.push(None),
PresentationElement::Shape(shape) => patches.push(Some(ShapeXmlPatch {
line_style: shape
.stroke
.as_ref()
.map(|stroke| stroke.style)
.filter(|style| *style != LineStyle::Solid),
flip_horizontal: shape.flip_horizontal,
flip_vertical: shape.flip_vertical,
})),
PresentationElement::Image(ImageElement { payload: None, .. }) => patches.push(None),
PresentationElement::Connector(_)
| PresentationElement::Image(_)
| PresentationElement::Table(_)
| PresentationElement::Chart(_) => {}
}
}
if patches.iter().all(|patch| {
patch.is_none_or(|patch| {
patch.line_style.is_none() && !patch.flip_horizontal && !patch.flip_vertical
})
}) {
return Ok(existing);
}
let mut updated = String::with_capacity(existing.len());
let mut remaining = existing.as_str();
for patch in patches {
let Some(start) = remaining.find("<p:sp>") else {
return Err("slide xml is missing an expected `<p:sp>` block".to_string());
};
let end = remaining[start..]
.find("</p:sp>")
.map(|offset| start + offset + "</p:sp>".len())
.ok_or_else(|| "slide xml is missing a closing `</p:sp>` block".to_string())?;
updated.push_str(&remaining[..start]);
let block = &remaining[start..end];
if let Some(patch) = patch {
updated.push_str(&patch_shape_block(block, patch)?);
} else {
updated.push_str(block);
}
remaining = &remaining[end..];
}
updated.push_str(remaining);
Ok(updated)
}
fn patch_shape_block(block: &str, patch: ShapeXmlPatch) -> Result<String, String> {
let block = if let Some(line_style) = patch.line_style {
patch_shape_block_dash(block, line_style)?
} else {
block.to_string()
};
if patch.flip_horizontal || patch.flip_vertical {
patch_shape_block_flip(&block, patch.flip_horizontal, patch.flip_vertical)
} else {
Ok(block)
}
}
fn patch_shape_block_dash(block: &str, line_style: LineStyle) -> Result<String, String> {
let Some(line_start) = block.find("<a:ln") else {
return Err("shape block is missing an `<a:ln>` entry for stroke styling".to_string());
};
if let Some(dash_start) = block[line_start..].find("<a:prstDash") {
let dash_start = line_start + dash_start;
let dash_end = block[dash_start..]
.find("/>")
.map(|offset| dash_start + offset + 2)
.ok_or_else(|| "shape line dash entry is missing a closing `/>`".to_string())?;
let mut patched = String::with_capacity(block.len() + 32);
patched.push_str(&block[..dash_start]);
patched.push_str(&format!(
r#"<a:prstDash val="{}"/>"#,
line_style.to_ppt_xml()
));
patched.push_str(&block[dash_end..]);
return Ok(patched);
}
if let Some(line_end) = block[line_start..].find("</a:ln>") {
let line_end = line_start + line_end;
let mut patched = String::with_capacity(block.len() + 32);
patched.push_str(&block[..line_end]);
patched.push_str(&format!(
r#"<a:prstDash val="{}"/>"#,
line_style.to_ppt_xml()
));
patched.push_str(&block[line_end..]);
return Ok(patched);
}
let line_end = block[line_start..]
.find("/>")
.map(|offset| line_start + offset + 2)
.ok_or_else(|| "shape line entry is missing a closing marker".to_string())?;
let line_tag = &block[line_start..line_end - 2];
let mut patched = String::with_capacity(block.len() + 48);
patched.push_str(&block[..line_start]);
patched.push_str(line_tag);
patched.push('>');
patched.push_str(&format!(
r#"<a:prstDash val="{}"/>"#,
line_style.to_ppt_xml()
));
patched.push_str("</a:ln>");
patched.push_str(&block[line_end..]);
Ok(patched)
}
fn patch_shape_block_flip(
block: &str,
flip_horizontal: bool,
flip_vertical: bool,
) -> Result<String, String> {
let Some(xfrm_start) = block.find("<a:xfrm") else {
return Err("shape block is missing an `<a:xfrm>` entry for flip styling".to_string());
};
let tag_end = block[xfrm_start..]
.find('>')
.map(|offset| xfrm_start + offset)
.ok_or_else(|| "shape transform entry is missing a closing `>`".to_string())?;
let tag = &block[xfrm_start..=tag_end];
let mut patched_tag = tag.to_string();
patched_tag = upsert_xml_attribute(
&patched_tag,
"flipH",
if flip_horizontal { "1" } else { "0" },
);
patched_tag =
upsert_xml_attribute(&patched_tag, "flipV", if flip_vertical { "1" } else { "0" });
Ok(format!(
"{}{}{}",
&block[..xfrm_start],
patched_tag,
&block[tag_end + 1..]
))
}
fn upsert_xml_attribute(tag: &str, attribute: &str, value: &str) -> String {
let needle = format!(r#"{attribute}=""#);
if let Some(start) = tag.find(&needle) {
let value_start = start + needle.len();
if let Some(end_offset) = tag[value_start..].find('"') {
let end = value_start + end_offset;
return format!("{}{}{}", &tag[..value_start], value, &tag[end..]);
}
}
let insert_at = tag.len() - 1;
format!(r#"{} {attribute}="{value}""#, &tag[..insert_at]) + &tag[insert_at..]
}
fn slide_table_xml(slide: &PresentationSlide) -> String {
let mut ordered = slide.elements.iter().collect::<Vec<_>>();
ordered.sort_by_key(|element| element.z_order());
let mut table_index = 0_usize;
ordered
.into_iter()
.filter_map(|element| {
let PresentationElement::Table(table) = element else {
return None;
};
table_index += 1;
let rows = table
.rows
.clone()
.into_iter()
.enumerate()
.map(|(row_index, row)| {
let cells = row
.into_iter()
.enumerate()
.map(|(column_index, cell)| {
build_table_cell(cell, &table.merges, row_index, column_index)
})
.collect::<Vec<_>>();
let mut table_row = TableRow::new(cells);
if let Some(height) = table.row_heights.get(row_index) {
table_row = table_row.with_height(points_to_emu(*height));
}
Some(table_row)
})
.collect::<Option<Vec<_>>>()?;
Some(ppt_rs::generator::table::generate_table_xml(
&ppt_rs::generator::table::Table::new(
rows,
table
.column_widths
.iter()
.copied()
.map(points_to_emu)
.collect(),
points_to_emu(table.frame.left),
points_to_emu(table.frame.top),
),
300 + table_index,
))
})
.collect::<Vec<_>>()
.join("\n")
}
fn write_preview_images(
document: &PresentationDocument,
output_dir: &Path,
action: &str,
) -> Result<(), PresentationArtifactError> {
let pptx_path = output_dir.join("preview.pptx");
let bytes = build_pptx_bytes(document, action).map_err(|message| {
PresentationArtifactError::ExportFailed {
path: pptx_path.clone(),
message,
}
})?;
std::fs::write(&pptx_path, bytes).map_err(|error| PresentationArtifactError::ExportFailed {
path: pptx_path.clone(),
message: error.to_string(),
})?;
render_pptx_to_pngs(&pptx_path, output_dir, action)
}
fn render_pptx_to_pngs(
pptx_path: &Path,
output_dir: &Path,
action: &str,
) -> Result<(), PresentationArtifactError> {
let soffice_cmd = if cfg!(target_os = "macos")
&& Path::new("/Applications/LibreOffice.app/Contents/MacOS/soffice").exists()
{
"/Applications/LibreOffice.app/Contents/MacOS/soffice"
} else {
"soffice"
};
let conversion = Command::new(soffice_cmd)
.arg("--headless")
.arg("--convert-to")
.arg("pdf")
.arg(pptx_path)
.arg("--outdir")
.arg(output_dir)
.output()
.map_err(|error| PresentationArtifactError::ExportFailed {
path: pptx_path.to_path_buf(),
message: format!("{action}: failed to execute LibreOffice: {error}"),
})?;
if !conversion.status.success() {
return Err(PresentationArtifactError::ExportFailed {
path: pptx_path.to_path_buf(),
message: format!(
"{action}: LibreOffice conversion failed: {}",
String::from_utf8_lossy(&conversion.stderr)
),
});
}
let pdf_path = output_dir.join(
pptx_path
.file_stem()
.and_then(|stem| stem.to_str())
.map(|stem| format!("{stem}.pdf"))
.ok_or_else(|| PresentationArtifactError::ExportFailed {
path: pptx_path.to_path_buf(),
message: format!("{action}: preview pptx filename is invalid"),
})?,
);
let prefix = output_dir.join("slide");
let conversion = Command::new("pdftoppm")
.arg("-png")
.arg(&pdf_path)
.arg(&prefix)
.output()
.map_err(|error| PresentationArtifactError::ExportFailed {
path: pdf_path.clone(),
message: format!("{action}: failed to execute pdftoppm: {error}"),
})?;
std::fs::remove_file(&pdf_path).ok();
if !conversion.status.success() {
return Err(PresentationArtifactError::ExportFailed {
path: output_dir.to_path_buf(),
message: format!(
"{action}: pdftoppm conversion failed: {}",
String::from_utf8_lossy(&conversion.stderr)
),
});
}
Ok(())
}
pub(crate) fn write_preview_image(
source_path: &Path,
target_path: &Path,
format: PreviewOutputFormat,
scale: f32,
quality: u8,
action: &str,
) -> Result<(), PresentationArtifactError> {
if matches!(format, PreviewOutputFormat::Png) && scale == 1.0 {
std::fs::rename(source_path, target_path).map_err(|error| {
PresentationArtifactError::ExportFailed {
path: target_path.to_path_buf(),
message: error.to_string(),
}
})?;
return Ok(());
}
let mut preview =
image::open(source_path).map_err(|error| PresentationArtifactError::ExportFailed {
path: source_path.to_path_buf(),
message: format!("{action}: {error}"),
})?;
if scale != 1.0 {
let width = (preview.width() as f32 * scale).round().max(1.0) as u32;
let height = (preview.height() as f32 * scale).round().max(1.0) as u32;
preview = preview.resize_exact(width, height, FilterType::Lanczos3);
}
let file = std::fs::File::create(target_path).map_err(|error| {
PresentationArtifactError::ExportFailed {
path: target_path.to_path_buf(),
message: error.to_string(),
}
})?;
let mut writer = std::io::BufWriter::new(file);
match format {
PreviewOutputFormat::Png => {
preview
.write_to(&mut writer, ImageFormat::Png)
.map_err(|error| PresentationArtifactError::ExportFailed {
path: target_path.to_path_buf(),
message: format!("{action}: {error}"),
})?
}
PreviewOutputFormat::Jpeg => {
let rgb = preview.to_rgb8();
let mut encoder = JpegEncoder::new_with_quality(&mut writer, quality);
encoder.encode_image(&rgb).map_err(|error| {
PresentationArtifactError::ExportFailed {
path: target_path.to_path_buf(),
message: format!("{action}: {error}"),
}
})?;
}
PreviewOutputFormat::Svg => {
let mut png_bytes = Cursor::new(Vec::new());
preview
.write_to(&mut png_bytes, ImageFormat::Png)
.map_err(|error| PresentationArtifactError::ExportFailed {
path: target_path.to_path_buf(),
message: format!("{action}: {error}"),
})?;
let embedded_png = BASE64_STANDARD.encode(png_bytes.into_inner());
let svg = format!(
r#"<svg xmlns="http://www.w3.org/2000/svg" width="{}" height="{}" viewBox="0 0 {} {}"><image href="data:image/png;base64,{embedded_png}" width="{}" height="{}"/></svg>"#,
preview.width(),
preview.height(),
preview.width(),
preview.height(),
preview.width(),
preview.height(),
);
writer.write_all(svg.as_bytes()).map_err(|error| {
PresentationArtifactError::ExportFailed {
path: target_path.to_path_buf(),
message: format!("{action}: {error}"),
}
})?;
}
}
std::fs::remove_file(source_path).ok();
Ok(())
}
fn collect_pngs(output_dir: &Path) -> Result<Vec<PathBuf>, PresentationArtifactError> {
let mut files = std::fs::read_dir(output_dir)
.map_err(|error| PresentationArtifactError::ExportFailed {
path: output_dir.to_path_buf(),
message: error.to_string(),
})?
.filter_map(Result::ok)
.map(|entry| entry.path())
.filter(|path| path.extension().and_then(|ext| ext.to_str()) == Some("png"))
.collect::<Vec<_>>();
files.sort();
Ok(files)
}
fn parse_preview_output_format(
format: Option<&str>,
path: &Path,
action: &str,
) -> Result<PreviewOutputFormat, PresentationArtifactError> {
let value = format
.map(str::to_owned)
.or_else(|| {
path.extension()
.and_then(|extension| extension.to_str())
.map(str::to_owned)
})
.unwrap_or_else(|| "png".to_string());
match value.to_ascii_lowercase().as_str() {
"png" => Ok(PreviewOutputFormat::Png),
"jpg" | "jpeg" => Ok(PreviewOutputFormat::Jpeg),
"svg" => Ok(PreviewOutputFormat::Svg),
other => Err(PresentationArtifactError::InvalidArgs {
action: action.to_string(),
message: format!("preview format `{other}` is not supported"),
}),
}
}
fn normalize_preview_scale(
scale: Option<f32>,
action: &str,
) -> Result<f32, PresentationArtifactError> {
let scale = scale.unwrap_or(1.0);
if !scale.is_finite() || scale <= 0.0 {
return Err(PresentationArtifactError::InvalidArgs {
action: action.to_string(),
message: "`scale` must be a positive number".to_string(),
});
}
Ok(scale)
}
fn normalize_preview_quality(
quality: Option<u8>,
action: &str,
) -> Result<u8, PresentationArtifactError> {
let quality = quality.unwrap_or(90);
if quality == 0 || quality > 100 {
return Err(PresentationArtifactError::InvalidArgs {
action: action.to_string(),
message: "`quality` must be between 1 and 100".to_string(),
});
}
Ok(quality)
}

View File

@@ -1,614 +0,0 @@
fn document_to_proto(
document: &PresentationDocument,
action: &str,
) -> Result<Value, PresentationArtifactError> {
let layouts = document
.layouts
.iter()
.map(|layout| layout_to_proto(document, layout, action))
.collect::<Result<Vec<_>, _>>()?;
let slides = document
.slides
.iter()
.enumerate()
.map(|(slide_index, slide)| slide_to_proto(slide, slide_index))
.collect::<Vec<_>>();
Ok(serde_json::json!({
"kind": "presentation",
"artifactId": document.artifact_id,
"anchor": format!("pr/{}", document.artifact_id),
"name": document.name,
"slideSize": rect_to_proto(document.slide_size),
"activeSlideIndex": document.active_slide_index,
"activeSlideId": document.active_slide_index.and_then(|index| document.slides.get(index)).map(|slide| slide.slide_id.clone()),
"theme": serde_json::json!({
"colorScheme": document.theme.color_scheme,
"hexColorMap": document.theme.color_scheme,
"majorFont": document.theme.major_font,
"minorFont": document.theme.minor_font,
}),
"styles": document
.named_text_styles()
.iter()
.map(|style| named_text_style_to_json(style, "st"))
.collect::<Vec<_>>(),
"masters": document.layouts.iter().filter(|layout| layout.kind == LayoutKind::Master).map(|layout| layout.layout_id.clone()).collect::<Vec<_>>(),
"layouts": layouts,
"slides": slides,
"commentAuthor": document.comment_self.as_ref().map(comment_author_to_proto),
"commentThreads": document
.comment_threads
.iter()
.map(comment_thread_to_proto)
.collect::<Vec<_>>(),
}))
}
fn layout_to_proto(
document: &PresentationDocument,
layout: &LayoutDocument,
action: &str,
) -> Result<Value, PresentationArtifactError> {
let placeholders = layout
.placeholders
.iter()
.map(placeholder_definition_to_proto)
.collect::<Vec<_>>();
let resolved_placeholders = resolved_layout_placeholders(document, &layout.layout_id, action)?
.into_iter()
.map(|placeholder| {
let mut value = placeholder_definition_to_proto(&placeholder.definition);
value["sourceLayoutId"] = Value::String(placeholder.source_layout_id);
value
})
.collect::<Vec<_>>();
Ok(serde_json::json!({
"layoutId": layout.layout_id,
"anchor": format!("ly/{}", layout.layout_id),
"name": layout.name,
"kind": match layout.kind {
LayoutKind::Layout => "layout",
LayoutKind::Master => "master",
},
"parentLayoutId": layout.parent_layout_id,
"placeholders": placeholders,
"resolvedPlaceholders": resolved_placeholders,
}))
}
fn placeholder_definition_to_proto(placeholder: &PlaceholderDefinition) -> Value {
serde_json::json!({
"name": placeholder.name,
"placeholderType": placeholder.placeholder_type,
"index": placeholder.index,
"text": placeholder.text,
"geometry": format!("{:?}", placeholder.geometry),
"frame": rect_to_proto(placeholder.frame),
})
}
fn slide_to_proto(slide: &PresentationSlide, slide_index: usize) -> Value {
serde_json::json!({
"slideId": slide.slide_id,
"anchor": format!("sl/{}", slide.slide_id),
"index": slide_index,
"layoutId": slide.layout_id,
"backgroundFill": slide.background_fill,
"notes": serde_json::json!({
"anchor": format!("nt/{}", slide.slide_id),
"text": slide.notes.text,
"visible": slide.notes.visible,
"textPreview": slide.notes.text.replace('\n', " | "),
"textChars": slide.notes.text.chars().count(),
"textLines": slide.notes.text.lines().count(),
"richText": rich_text_to_proto(&slide.notes.text, &slide.notes.rich_text),
}),
"elements": slide.elements.iter().map(element_to_proto).collect::<Vec<_>>(),
})
}
fn element_to_proto(element: &PresentationElement) -> Value {
match element {
PresentationElement::Text(text) => {
let mut record = serde_json::json!({
"kind": "text",
"elementId": text.element_id,
"anchor": format!("sh/{}", text.element_id),
"frame": rect_to_proto(text.frame),
"text": text.text,
"textPreview": text.text.replace('\n', " | "),
"textChars": text.text.chars().count(),
"textLines": text.text.lines().count(),
"fill": text.fill,
"style": text_style_to_proto(&text.style),
"richText": rich_text_to_proto(&text.text, &text.rich_text),
"zOrder": text.z_order,
});
if let Some(placeholder) = &text.placeholder {
record["placeholder"] = placeholder_ref_to_proto(placeholder);
}
if let Some(hyperlink) = &text.hyperlink {
record["hyperlink"] = hyperlink.to_json();
}
record
}
PresentationElement::Shape(shape) => {
let mut record = serde_json::json!({
"kind": "shape",
"elementId": shape.element_id,
"anchor": format!("sh/{}", shape.element_id),
"geometry": format!("{:?}", shape.geometry),
"frame": rect_to_proto(shape.frame),
"fill": shape.fill,
"stroke": shape.stroke.as_ref().map(stroke_to_proto),
"text": shape.text,
"textStyle": text_style_to_proto(&shape.text_style),
"richText": shape
.text
.as_ref()
.zip(shape.rich_text.as_ref())
.map(|(text, rich_text)| rich_text_to_proto(text, rich_text))
.unwrap_or(Value::Null),
"rotation": shape.rotation_degrees,
"flipHorizontal": shape.flip_horizontal,
"flipVertical": shape.flip_vertical,
"zOrder": shape.z_order,
});
if let Some(text) = &shape.text {
record["textPreview"] = Value::String(text.replace('\n', " | "));
record["textChars"] = Value::from(text.chars().count());
record["textLines"] = Value::from(text.lines().count());
}
if let Some(placeholder) = &shape.placeholder {
record["placeholder"] = placeholder_ref_to_proto(placeholder);
}
if let Some(hyperlink) = &shape.hyperlink {
record["hyperlink"] = hyperlink.to_json();
}
record
}
PresentationElement::Connector(connector) => serde_json::json!({
"kind": "connector",
"elementId": connector.element_id,
"anchor": format!("cn/{}", connector.element_id),
"connectorType": format!("{:?}", connector.connector_type),
"start": serde_json::json!({
"left": connector.start.left,
"top": connector.start.top,
"unit": "points",
}),
"end": serde_json::json!({
"left": connector.end.left,
"top": connector.end.top,
"unit": "points",
}),
"line": stroke_to_proto(&connector.line),
"lineStyle": connector.line_style.as_api_str(),
"startArrow": format!("{:?}", connector.start_arrow),
"endArrow": format!("{:?}", connector.end_arrow),
"arrowSize": format!("{:?}", connector.arrow_size),
"label": connector.label,
"zOrder": connector.z_order,
}),
PresentationElement::Image(image) => {
let mut record = serde_json::json!({
"kind": "image",
"elementId": image.element_id,
"anchor": format!("im/{}", image.element_id),
"frame": rect_to_proto(image.frame),
"fit": format!("{:?}", image.fit_mode),
"crop": image.crop.map(|(left, top, right, bottom)| serde_json::json!({
"left": left,
"top": top,
"right": right,
"bottom": bottom,
})),
"rotation": image.rotation_degrees,
"flipHorizontal": image.flip_horizontal,
"flipVertical": image.flip_vertical,
"lockAspectRatio": image.lock_aspect_ratio,
"alt": image.alt_text,
"prompt": image.prompt,
"isPlaceholder": image.is_placeholder,
"payload": image.payload.as_ref().map(image_payload_to_proto),
"zOrder": image.z_order,
});
if let Some(placeholder) = &image.placeholder {
record["placeholder"] = placeholder_ref_to_proto(placeholder);
}
record
}
PresentationElement::Table(table) => serde_json::json!({
"kind": "table",
"elementId": table.element_id,
"anchor": format!("tb/{}", table.element_id),
"frame": rect_to_proto(table.frame),
"rows": table.rows.iter().map(|row| {
row.iter().map(table_cell_to_proto).collect::<Vec<_>>()
}).collect::<Vec<_>>(),
"columnWidths": table.column_widths,
"rowHeights": table.row_heights,
"style": table.style,
"styleOptions": table_style_options_to_proto(&table.style_options),
"borders": table.borders.as_ref().map(table_borders_to_proto),
"rightToLeft": table.right_to_left,
"merges": table.merges.iter().map(|merge| serde_json::json!({
"startRow": merge.start_row,
"endRow": merge.end_row,
"startColumn": merge.start_column,
"endColumn": merge.end_column,
})).collect::<Vec<_>>(),
"zOrder": table.z_order,
}),
PresentationElement::Chart(chart) => serde_json::json!({
"kind": "chart",
"elementId": chart.element_id,
"anchor": format!("ch/{}", chart.element_id),
"frame": rect_to_proto(chart.frame),
"chartType": format!("{:?}", chart.chart_type),
"title": chart.title,
"categories": chart.categories,
"styleIndex": chart.style_index,
"hasLegend": chart.has_legend,
"legend": chart.legend.as_ref().map(chart_legend_to_proto),
"xAxis": chart.x_axis.as_ref().map(chart_axis_to_proto),
"yAxis": chart.y_axis.as_ref().map(chart_axis_to_proto),
"dataLabels": chart.data_labels.as_ref().map(chart_data_labels_to_proto),
"chartFill": chart.chart_fill,
"plotAreaFill": chart.plot_area_fill,
"series": chart.series.iter().map(|series| serde_json::json!({
"name": series.name,
"values": series.values,
"categories": series.categories,
"xValues": series.x_values,
"fill": series.fill,
"stroke": series.stroke.as_ref().map(stroke_to_proto),
"marker": series.marker.as_ref().map(chart_marker_to_proto),
"dataLabelOverrides": series
.data_label_overrides
.iter()
.map(chart_data_label_override_to_proto)
.collect::<Vec<_>>(),
})).collect::<Vec<_>>(),
"zOrder": chart.z_order,
}),
}
}
fn rect_to_proto(rect: Rect) -> Value {
serde_json::json!({
"left": rect.left,
"top": rect.top,
"width": rect.width,
"height": rect.height,
"unit": "points",
})
}
fn stroke_to_proto(stroke: &StrokeStyle) -> Value {
serde_json::json!({
"color": stroke.color,
"width": stroke.width,
"style": stroke.style.as_api_str(),
"unit": "points",
})
}
fn text_style_to_proto(style: &TextStyle) -> Value {
serde_json::json!({
"styleName": style.style_name,
"fontSize": style.font_size,
"fontFamily": style.font_family,
"color": style.color,
"alignment": style.alignment,
"bold": style.bold,
"italic": style.italic,
"underline": style.underline,
})
}
fn rich_text_to_proto(text: &str, rich_text: &RichTextState) -> Value {
serde_json::json!({
"layout": text_layout_to_proto(&rich_text.layout),
"ranges": rich_text
.ranges
.iter()
.map(|range| text_range_to_proto(text, range))
.collect::<Vec<_>>(),
})
}
fn text_range_to_proto(text: &str, range: &TextRangeAnnotation) -> Value {
serde_json::json!({
"rangeId": range.range_id,
"anchor": format!("tr/{}", range.range_id),
"startCp": range.start_cp,
"length": range.length,
"text": text_slice_by_codepoint_range(text, range.start_cp, range.length),
"style": text_style_to_proto(&range.style),
"hyperlink": range.hyperlink.as_ref().map(HyperlinkState::to_json),
"spacingBefore": range.spacing_before,
"spacingAfter": range.spacing_after,
"lineSpacing": range.line_spacing,
})
}
fn text_layout_to_proto(layout: &TextLayoutState) -> Value {
serde_json::json!({
"insets": layout.insets.map(|insets| serde_json::json!({
"left": insets.left,
"right": insets.right,
"top": insets.top,
"bottom": insets.bottom,
"unit": "points",
})),
"wrap": layout.wrap.map(text_wrap_mode_to_proto),
"autoFit": layout.auto_fit.map(text_auto_fit_mode_to_proto),
"verticalAlignment": layout
.vertical_alignment
.map(text_vertical_alignment_to_proto),
})
}
fn text_wrap_mode_to_proto(mode: TextWrapMode) -> &'static str {
match mode {
TextWrapMode::Square => "square",
TextWrapMode::None => "none",
}
}
fn text_auto_fit_mode_to_proto(mode: TextAutoFitMode) -> &'static str {
match mode {
TextAutoFitMode::None => "none",
TextAutoFitMode::ShrinkText => "shrinkText",
TextAutoFitMode::ResizeShapeToFitText => "resizeShapeToFitText",
}
}
fn text_vertical_alignment_to_proto(alignment: TextVerticalAlignment) -> &'static str {
match alignment {
TextVerticalAlignment::Top => "top",
TextVerticalAlignment::Middle => "middle",
TextVerticalAlignment::Bottom => "bottom",
}
}
fn placeholder_ref_to_proto(placeholder: &PlaceholderRef) -> Value {
serde_json::json!({
"name": placeholder.name,
"placeholderType": placeholder.placeholder_type,
"index": placeholder.index,
})
}
fn image_payload_to_proto(payload: &ImagePayload) -> Value {
serde_json::json!({
"format": payload.format,
"widthPx": payload.width_px,
"heightPx": payload.height_px,
"bytesBase64": BASE64_STANDARD.encode(&payload.bytes),
})
}
fn table_cell_to_proto(cell: &TableCellSpec) -> Value {
serde_json::json!({
"text": cell.text,
"textStyle": text_style_to_proto(&cell.text_style),
"richText": rich_text_to_proto(&cell.text, &cell.rich_text),
"backgroundFill": cell.background_fill,
"alignment": cell.alignment,
"borders": cell.borders.as_ref().map(table_borders_to_proto),
})
}
fn table_style_options_to_proto(style_options: &TableStyleOptions) -> Value {
serde_json::json!({
"headerRow": style_options.header_row,
"bandedRows": style_options.banded_rows,
"bandedColumns": style_options.banded_columns,
"firstColumn": style_options.first_column,
"lastColumn": style_options.last_column,
"totalRow": style_options.total_row,
})
}
fn table_borders_to_proto(borders: &TableBorders) -> Value {
serde_json::json!({
"outside": borders.outside.as_ref().map(table_border_to_proto),
"inside": borders.inside.as_ref().map(table_border_to_proto),
"top": borders.top.as_ref().map(table_border_to_proto),
"bottom": borders.bottom.as_ref().map(table_border_to_proto),
"left": borders.left.as_ref().map(table_border_to_proto),
"right": borders.right.as_ref().map(table_border_to_proto),
})
}
fn table_border_to_proto(border: &TableBorder) -> Value {
serde_json::json!({
"color": border.color,
"width": border.width,
"unit": "points",
})
}
fn chart_marker_to_proto(marker: &ChartMarkerStyle) -> Value {
serde_json::json!({
"symbol": marker.symbol,
"size": marker.size,
})
}
fn chart_data_labels_to_proto(data_labels: &ChartDataLabels) -> Value {
serde_json::json!({
"showValue": data_labels.show_value,
"showCategoryName": data_labels.show_category_name,
"showLeaderLines": data_labels.show_leader_lines,
"position": data_labels.position,
"textStyle": text_style_to_proto(&data_labels.text_style),
})
}
fn chart_legend_to_proto(legend: &ChartLegend) -> Value {
serde_json::json!({
"position": legend.position,
"textStyle": text_style_to_proto(&legend.text_style),
})
}
fn chart_axis_to_proto(axis: &ChartAxisSpec) -> Value {
serde_json::json!({
"title": axis.title,
})
}
fn chart_data_label_override_to_proto(override_spec: &ChartDataLabelOverride) -> Value {
serde_json::json!({
"idx": override_spec.idx,
"text": override_spec.text,
"position": override_spec.position,
"textStyle": text_style_to_proto(&override_spec.text_style),
"fill": override_spec.fill,
"stroke": override_spec.stroke.as_ref().map(stroke_to_proto),
})
}
fn comment_author_to_proto(author: &CommentAuthorProfile) -> Value {
serde_json::json!({
"displayName": author.display_name,
"initials": author.initials,
"email": author.email,
})
}
fn comment_thread_to_proto(thread: &CommentThread) -> Value {
serde_json::json!({
"kind": "comment",
"threadId": thread.thread_id,
"anchor": format!("th/{}", thread.thread_id),
"target": comment_target_to_proto(&thread.target),
"position": thread.position.as_ref().map(comment_position_to_proto),
"status": comment_status_to_proto(thread.status),
"messages": thread.messages.iter().map(comment_message_to_proto).collect::<Vec<_>>(),
})
}
fn comment_target_to_proto(target: &CommentTarget) -> Value {
match target {
CommentTarget::Slide { slide_id } => serde_json::json!({
"type": "slide",
"slideId": slide_id,
"slideAnchor": format!("sl/{slide_id}"),
}),
CommentTarget::Element {
slide_id,
element_id,
} => serde_json::json!({
"type": "element",
"slideId": slide_id,
"slideAnchor": format!("sl/{slide_id}"),
"elementId": element_id,
"elementAnchor": format!("sh/{element_id}"),
}),
CommentTarget::TextRange {
slide_id,
element_id,
start_cp,
length,
context,
} => serde_json::json!({
"type": "textRange",
"slideId": slide_id,
"slideAnchor": format!("sl/{slide_id}"),
"elementId": element_id,
"elementAnchor": format!("sh/{element_id}"),
"startCp": start_cp,
"length": length,
"context": context,
}),
}
}
fn comment_position_to_proto(position: &CommentPosition) -> Value {
serde_json::json!({
"x": position.x,
"y": position.y,
"unit": "points",
})
}
fn comment_message_to_proto(message: &CommentMessage) -> Value {
serde_json::json!({
"messageId": message.message_id,
"author": comment_author_to_proto(&message.author),
"text": message.text,
"createdAt": message.created_at,
"reactions": message.reactions,
})
}
fn comment_status_to_proto(status: CommentThreadStatus) -> &'static str {
match status {
CommentThreadStatus::Active => "active",
CommentThreadStatus::Resolved => "resolved",
}
}
fn text_slice_by_codepoint_range(text: &str, start_cp: usize, length: usize) -> String {
text.chars().skip(start_cp).take(length).collect()
}
fn build_table_cell(
cell: TableCellSpec,
merges: &[TableMergeRegion],
row_index: usize,
column_index: usize,
) -> TableCell {
let mut table_cell = TableCell::new(&cell.text);
if cell.text_style.bold {
table_cell = table_cell.bold();
}
if cell.text_style.italic {
table_cell = table_cell.italic();
}
if cell.text_style.underline {
table_cell = table_cell.underline();
}
if let Some(color) = cell.text_style.color {
table_cell = table_cell.text_color(&color);
}
if let Some(fill) = cell.background_fill {
table_cell = table_cell.background_color(&fill);
}
if let Some(size) = cell.text_style.font_size {
table_cell = table_cell.font_size(size);
}
if let Some(font_family) = cell.text_style.font_family {
table_cell = table_cell.font_family(&font_family);
}
if let Some(alignment) = cell.alignment.or(cell.text_style.alignment) {
table_cell = match alignment {
TextAlignment::Left => table_cell.align_left(),
TextAlignment::Center => table_cell.align_center(),
TextAlignment::Right => table_cell.align_right(),
TextAlignment::Justify => table_cell.align(CellAlign::Justify),
};
}
for merge in merges {
if row_index == merge.start_row && column_index == merge.start_column {
table_cell = table_cell
.grid_span((merge.end_column - merge.start_column + 1) as u32)
.row_span((merge.end_row - merge.start_row + 1) as u32);
} else if row_index >= merge.start_row
&& row_index <= merge.end_row
&& column_index >= merge.start_column
&& column_index <= merge.end_column
{
if row_index == merge.start_row {
table_cell = table_cell.h_merge();
} else {
table_cell = table_cell.v_merge();
}
}
}
table_cell
}

View File

@@ -1,138 +0,0 @@
#[derive(Debug, Clone, Serialize)]
pub struct PresentationArtifactResponse {
pub artifact_id: String,
pub action: String,
pub summary: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub executed_actions: Option<Vec<String>>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub exported_paths: Vec<PathBuf>,
#[serde(skip_serializing_if = "Option::is_none")]
pub artifact_snapshot: Option<ArtifactSnapshot>,
#[serde(skip_serializing_if = "Option::is_none")]
pub slide_list: Option<Vec<SlideListEntry>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub layout_list: Option<Vec<LayoutListEntry>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub placeholder_list: Option<Vec<PlaceholderListEntry>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub theme: Option<ThemeSnapshot>,
#[serde(skip_serializing_if = "Option::is_none")]
pub inspect_ndjson: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub resolved_record: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub proto_json: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub patch: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub active_slide_index: Option<usize>,
}
impl PresentationArtifactResponse {
fn new(
artifact_id: String,
action: String,
summary: String,
artifact_snapshot: ArtifactSnapshot,
) -> Self {
Self {
artifact_id,
action,
summary,
executed_actions: None,
exported_paths: Vec::new(),
artifact_snapshot: Some(artifact_snapshot),
slide_list: None,
layout_list: None,
placeholder_list: None,
theme: None,
inspect_ndjson: None,
resolved_record: None,
proto_json: None,
patch: None,
active_slide_index: None,
}
}
}
fn response_for_document_state(
artifact_id: String,
action: String,
summary: String,
document: Option<&PresentationDocument>,
) -> PresentationArtifactResponse {
PresentationArtifactResponse {
artifact_id,
action,
summary,
executed_actions: None,
exported_paths: Vec::new(),
artifact_snapshot: document.map(snapshot_for_document),
slide_list: None,
layout_list: None,
placeholder_list: None,
theme: document.map(PresentationDocument::theme_snapshot),
inspect_ndjson: None,
resolved_record: None,
proto_json: None,
patch: None,
active_slide_index: document.and_then(|current| current.active_slide_index),
}
}
#[derive(Debug, Clone, Serialize)]
pub struct ArtifactSnapshot {
pub slide_count: usize,
pub slides: Vec<SlideSnapshot>,
}
#[derive(Debug, Clone, Serialize)]
pub struct SlideSnapshot {
pub slide_id: String,
pub index: usize,
pub element_ids: Vec<String>,
pub element_types: Vec<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct SlideListEntry {
pub slide_id: String,
pub index: usize,
pub is_active: bool,
pub notes: Option<String>,
pub notes_visible: bool,
pub background_fill: Option<String>,
pub layout_id: Option<String>,
pub element_count: usize,
}
#[derive(Debug, Clone, Serialize)]
pub struct LayoutListEntry {
pub layout_id: String,
pub name: String,
pub kind: String,
pub parent_layout_id: Option<String>,
pub placeholder_count: usize,
}
#[derive(Debug, Clone, Serialize)]
pub struct PlaceholderListEntry {
pub scope: String,
pub source_layout_id: Option<String>,
pub slide_index: Option<usize>,
pub element_id: Option<String>,
pub name: String,
pub placeholder_type: String,
pub index: Option<u32>,
pub geometry: Option<String>,
pub text_preview: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct ThemeSnapshot {
pub color_scheme: HashMap<String, String>,
pub hex_color_map: HashMap<String, String>,
pub major_font: Option<String>,
pub minor_font: Option<String>,
}

View File

@@ -1,339 +0,0 @@
fn cell_value_to_string(value: Value) -> String {
match value {
Value::Null => String::new(),
Value::String(text) => text,
Value::Bool(boolean) => boolean.to_string(),
Value::Number(number) => number.to_string(),
other => other.to_string(),
}
}
fn snapshot_for_document(document: &PresentationDocument) -> ArtifactSnapshot {
ArtifactSnapshot {
slide_count: document.slides.len(),
slides: document
.slides
.iter()
.enumerate()
.map(|(index, slide)| SlideSnapshot {
slide_id: slide.slide_id.clone(),
index,
element_ids: slide
.elements
.iter()
.map(|element| element.element_id().to_string())
.collect(),
element_types: slide
.elements
.iter()
.map(|element| element.kind().to_string())
.collect(),
})
.collect(),
}
}
fn slide_list(document: &PresentationDocument) -> Vec<SlideListEntry> {
document
.slides
.iter()
.enumerate()
.map(|(index, slide)| SlideListEntry {
slide_id: slide.slide_id.clone(),
index,
is_active: document.active_slide_index == Some(index),
notes: (slide.notes.visible && !slide.notes.text.is_empty())
.then(|| slide.notes.text.clone()),
notes_visible: slide.notes.visible,
background_fill: slide.background_fill.clone(),
layout_id: slide.layout_id.clone(),
element_count: slide.elements.len(),
})
.collect()
}
fn layout_list(document: &PresentationDocument) -> Vec<LayoutListEntry> {
document
.layouts
.iter()
.map(|layout| LayoutListEntry {
layout_id: layout.layout_id.clone(),
name: layout.name.clone(),
kind: match layout.kind {
LayoutKind::Layout => "layout".to_string(),
LayoutKind::Master => "master".to_string(),
},
parent_layout_id: layout.parent_layout_id.clone(),
placeholder_count: layout.placeholders.len(),
})
.collect()
}
fn points_to_emu(points: u32) -> u32 {
points.saturating_mul(POINT_TO_EMU)
}
fn emu_to_points(emu: u32) -> u32 {
emu / POINT_TO_EMU
}
type ImageCrop = (f64, f64, f64, f64);
type FittedImage = (u32, u32, u32, u32, Option<ImageCrop>);
pub(crate) fn fit_image(image: &ImageElement) -> FittedImage {
let Some(payload) = image.payload.as_ref() else {
return (
image.frame.left,
image.frame.top,
image.frame.width,
image.frame.height,
None,
);
};
let frame = image.frame;
let source_width = payload.width_px as f64;
let source_height = payload.height_px as f64;
let target_width = frame.width as f64;
let target_height = frame.height as f64;
let source_ratio = source_width / source_height;
let target_ratio = target_width / target_height;
match image.fit_mode {
ImageFitMode::Stretch => (frame.left, frame.top, frame.width, frame.height, None),
ImageFitMode::Contain => {
let scale = if source_ratio > target_ratio {
target_width / source_width
} else {
target_height / source_height
};
let width = (source_width * scale).round() as u32;
let height = (source_height * scale).round() as u32;
let left = frame.left + frame.width.saturating_sub(width) / 2;
let top = frame.top + frame.height.saturating_sub(height) / 2;
(left, top, width, height, None)
}
ImageFitMode::Cover => {
let scale = if source_ratio > target_ratio {
target_height / source_height
} else {
target_width / source_width
};
let width = source_width * scale;
let height = source_height * scale;
let crop_x = ((width - target_width).max(0.0) / width) / 2.0;
let crop_y = ((height - target_height).max(0.0) / height) / 2.0;
(
frame.left,
frame.top,
frame.width,
frame.height,
Some((crop_x, crop_y, crop_x, crop_y)),
)
}
}
}
fn normalize_image_crop(
crop: ImageCropArgs,
action: &str,
) -> Result<ImageCrop, PresentationArtifactError> {
for (name, value) in [
("left", crop.left),
("top", crop.top),
("right", crop.right),
("bottom", crop.bottom),
] {
if !(0.0..=1.0).contains(&value) {
return Err(PresentationArtifactError::InvalidArgs {
action: action.to_string(),
message: format!("image crop `{name}` must be between 0.0 and 1.0"),
});
}
}
Ok((crop.left, crop.top, crop.right, crop.bottom))
}
fn load_image_payload_from_path(
path: &Path,
action: &str,
) -> Result<ImagePayload, PresentationArtifactError> {
let bytes = std::fs::read(path).map_err(|error| PresentationArtifactError::InvalidArgs {
action: action.to_string(),
message: format!("failed to read image `{}`: {error}", path.display()),
})?;
build_image_payload(
bytes,
path.file_name()
.and_then(|name| name.to_str())
.unwrap_or("image")
.to_string(),
action,
)
}
fn load_image_payload_from_data_url(
data_url: &str,
action: &str,
) -> Result<ImagePayload, PresentationArtifactError> {
let (header, payload) =
data_url
.split_once(',')
.ok_or_else(|| PresentationArtifactError::InvalidArgs {
action: action.to_string(),
message: "data_url must include a MIME header and base64 payload".to_string(),
})?;
let mime = header
.strip_prefix("data:")
.and_then(|prefix| prefix.strip_suffix(";base64"))
.ok_or_else(|| PresentationArtifactError::InvalidArgs {
action: action.to_string(),
message: "data_url must be base64-encoded".to_string(),
})?;
let bytes = BASE64_STANDARD.decode(payload).map_err(|error| {
PresentationArtifactError::InvalidArgs {
action: action.to_string(),
message: format!("failed to decode image data_url: {error}"),
}
})?;
build_image_payload(
bytes,
format!("image.{}", image_extension_from_mime(mime)),
action,
)
}
fn load_image_payload_from_blob(
blob: &str,
action: &str,
) -> Result<ImagePayload, PresentationArtifactError> {
let bytes = BASE64_STANDARD.decode(blob.trim()).map_err(|error| {
PresentationArtifactError::InvalidArgs {
action: action.to_string(),
message: format!("failed to decode image blob: {error}"),
}
})?;
let extension = image::guess_format(&bytes)
.ok()
.map(image_extension_from_format)
.unwrap_or("png");
build_image_payload(bytes, format!("image.{extension}"), action)
}
fn load_image_payload_from_uri(
uri: &str,
action: &str,
) -> Result<ImagePayload, PresentationArtifactError> {
let response =
reqwest::blocking::get(uri).map_err(|error| PresentationArtifactError::InvalidArgs {
action: action.to_string(),
message: format!("failed to fetch image `{uri}`: {error}"),
})?;
let status = response.status();
if !status.is_success() {
return Err(PresentationArtifactError::InvalidArgs {
action: action.to_string(),
message: format!("failed to fetch image `{uri}`: HTTP {status}"),
});
}
let content_type = response
.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.map(|value| value.split(';').next().unwrap_or(value).trim().to_string());
let bytes = response
.bytes()
.map_err(|error| PresentationArtifactError::InvalidArgs {
action: action.to_string(),
message: format!("failed to read image `{uri}`: {error}"),
})?;
build_image_payload(
bytes.to_vec(),
infer_remote_image_filename(uri, content_type.as_deref()),
action,
)
}
fn infer_remote_image_filename(uri: &str, content_type: Option<&str>) -> String {
let path_name = reqwest::Url::parse(uri)
.ok()
.and_then(|url| {
url.path_segments()
.and_then(Iterator::last)
.map(str::to_owned)
})
.filter(|segment| !segment.is_empty());
match (path_name, content_type) {
(Some(path_name), _) if Path::new(&path_name).extension().is_some() => path_name,
(Some(path_name), Some(content_type)) => {
format!("{path_name}.{}", image_extension_from_mime(content_type))
}
(Some(path_name), None) => path_name,
(None, Some(content_type)) => format!("image.{}", image_extension_from_mime(content_type)),
(None, None) => "image.png".to_string(),
}
}
fn build_image_payload(
bytes: Vec<u8>,
filename: String,
action: &str,
) -> Result<ImagePayload, PresentationArtifactError> {
let image = image::load_from_memory(&bytes).map_err(|error| {
PresentationArtifactError::InvalidArgs {
action: action.to_string(),
message: format!("failed to decode image bytes: {error}"),
}
})?;
let (width_px, height_px) = image.dimensions();
let format = Path::new(&filename)
.extension()
.and_then(|extension| extension.to_str())
.unwrap_or("png")
.to_uppercase();
Ok(ImagePayload {
bytes,
format,
width_px,
height_px,
})
}
fn image_extension_from_mime(mime: &str) -> &'static str {
match mime {
"image/jpeg" => "jpg",
"image/gif" => "gif",
"image/webp" => "webp",
_ => "png",
}
}
fn image_extension_from_format(format: image::ImageFormat) -> &'static str {
match format {
image::ImageFormat::Jpeg => "jpg",
image::ImageFormat::Gif => "gif",
image::ImageFormat::WebP => "webp",
image::ImageFormat::Bmp => "bmp",
image::ImageFormat::Tiff => "tiff",
_ => "png",
}
}
fn index_out_of_range(action: &str, index: usize, len: usize) -> PresentationArtifactError {
PresentationArtifactError::InvalidArgs {
action: action.to_string(),
message: format!("slide index {index} is out of range for {len} slides"),
}
}
fn to_index(value: u32) -> Result<usize, PresentationArtifactError> {
usize::try_from(value).map_err(|_| PresentationArtifactError::InvalidArgs {
action: "insert_slide".to_string(),
message: "index does not fit in usize".to_string(),
})
}
fn resequence_z_order(slide: &mut PresentationSlide) {
for (index, element) in slide.elements.iter_mut().enumerate() {
element.set_z_order(index);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +0,0 @@
load("//:defs.bzl", "codex_rust_crate")
codex_rust_crate(
name = "artifact-spreadsheet",
crate_name = "codex_artifact_spreadsheet",
)

View File

@@ -1,25 +0,0 @@
[package]
name = "codex-artifact-spreadsheet"
version.workspace = true
edition.workspace = true
license.workspace = true
[lib]
name = "codex_artifact_spreadsheet"
path = "src/lib.rs"
[lints]
workspace = true
[dependencies]
base64 = { workspace = true }
regex = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
thiserror = { workspace = true }
uuid = { workspace = true, features = ["v4"] }
zip = { workspace = true }
[dev-dependencies]
pretty_assertions = { workspace = true }
tempfile = { workspace = true }

View File

@@ -1,245 +0,0 @@
use serde::Deserialize;
use serde::Serialize;
use crate::SpreadsheetArtifactError;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub struct CellAddress {
pub column: u32,
pub row: u32,
}
impl CellAddress {
pub fn parse(address: &str) -> Result<Self, SpreadsheetArtifactError> {
let trimmed = address.trim();
if trimmed.is_empty() {
return Err(SpreadsheetArtifactError::InvalidAddress {
address: address.to_string(),
message: "address is empty".to_string(),
});
}
let mut split = 0usize;
for (index, ch) in trimmed.char_indices() {
if ch.is_ascii_alphabetic() {
split = index + ch.len_utf8();
} else {
break;
}
}
let (letters, digits) = trimmed.split_at(split);
if letters.is_empty() || digits.is_empty() {
return Err(SpreadsheetArtifactError::InvalidAddress {
address: address.to_string(),
message: "expected A1-style address".to_string(),
});
}
if !letters.chars().all(|ch| ch.is_ascii_alphabetic())
|| !digits.chars().all(|ch| ch.is_ascii_digit())
{
return Err(SpreadsheetArtifactError::InvalidAddress {
address: address.to_string(),
message: "expected letters followed by digits".to_string(),
});
}
let column = column_letters_to_index(letters)?;
let row = digits
.parse::<u32>()
.map_err(|_| SpreadsheetArtifactError::InvalidAddress {
address: address.to_string(),
message: "row must be a positive integer".to_string(),
})?;
if row == 0 {
return Err(SpreadsheetArtifactError::InvalidAddress {
address: address.to_string(),
message: "row must be positive".to_string(),
});
}
Ok(Self { column, row })
}
pub fn to_a1(self) -> String {
format!("{}{}", column_index_to_letters(self.column), self.row)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CellRange {
pub start: CellAddress,
pub end: CellAddress,
}
impl CellRange {
pub fn parse(address: &str) -> Result<Self, SpreadsheetArtifactError> {
let trimmed = address.trim();
if trimmed.is_empty() {
return Err(SpreadsheetArtifactError::InvalidAddress {
address: address.to_string(),
message: "range is empty".to_string(),
});
}
let (start, end) = if let Some((left, right)) = trimmed.split_once(':') {
(CellAddress::parse(left)?, CellAddress::parse(right)?)
} else {
let cell = CellAddress::parse(trimmed)?;
(cell, cell)
};
let normalized = Self {
start: CellAddress {
column: start.column.min(end.column),
row: start.row.min(end.row),
},
end: CellAddress {
column: start.column.max(end.column),
row: start.row.max(end.row),
},
};
Ok(normalized)
}
pub fn from_start_end(start: CellAddress, end: CellAddress) -> Self {
Self {
start: CellAddress {
column: start.column.min(end.column),
row: start.row.min(end.row),
},
end: CellAddress {
column: start.column.max(end.column),
row: start.row.max(end.row),
},
}
}
pub fn to_a1(&self) -> String {
if self.is_single_cell() {
self.start.to_a1()
} else {
format!("{}:{}", self.start.to_a1(), self.end.to_a1())
}
}
pub fn is_single_cell(&self) -> bool {
self.start == self.end
}
pub fn is_single_row(&self) -> bool {
self.start.row == self.end.row
}
pub fn is_single_column(&self) -> bool {
self.start.column == self.end.column
}
pub fn width(&self) -> usize {
(self.end.column - self.start.column + 1) as usize
}
pub fn height(&self) -> usize {
(self.end.row - self.start.row + 1) as usize
}
pub fn contains(&self, address: CellAddress) -> bool {
self.start.column <= address.column
&& address.column <= self.end.column
&& self.start.row <= address.row
&& address.row <= self.end.row
}
pub fn contains_range(&self, other: &CellRange) -> bool {
self.contains(other.start) && self.contains(other.end)
}
pub fn intersects(&self, other: &CellRange) -> bool {
!(self.end.column < other.start.column
|| other.end.column < self.start.column
|| self.end.row < other.start.row
|| other.end.row < self.start.row)
}
pub fn addresses(&self) -> impl Iterator<Item = CellAddress> {
let range = self.clone();
(range.start.row..=range.end.row).flat_map(move |row| {
let range = range.clone();
(range.start.column..=range.end.column).map(move |column| CellAddress { column, row })
})
}
}
pub fn column_letters_to_index(column: &str) -> Result<u32, SpreadsheetArtifactError> {
let trimmed = column.trim();
if trimmed.is_empty() {
return Err(SpreadsheetArtifactError::InvalidAddress {
address: column.to_string(),
message: "column is empty".to_string(),
});
}
let mut result = 0u32;
for ch in trimmed.chars() {
if !ch.is_ascii_alphabetic() {
return Err(SpreadsheetArtifactError::InvalidAddress {
address: column.to_string(),
message: "column must contain only letters".to_string(),
});
}
result = result
.checked_mul(26)
.and_then(|value| value.checked_add((ch.to_ascii_uppercase() as u8 - b'A' + 1) as u32))
.ok_or_else(|| SpreadsheetArtifactError::InvalidAddress {
address: column.to_string(),
message: "column is too large".to_string(),
})?;
}
Ok(result)
}
pub fn column_index_to_letters(mut index: u32) -> String {
if index == 0 {
return String::new();
}
let mut letters = Vec::new();
while index > 0 {
let remainder = (index - 1) % 26;
letters.push((b'A' + remainder as u8) as char);
index = (index - 1) / 26;
}
letters.iter().rev().collect()
}
pub fn parse_column_reference(reference: &str) -> Result<(u32, u32), SpreadsheetArtifactError> {
let trimmed = reference.trim();
if let Some((left, right)) = trimmed.split_once(':') {
let start = column_letters_to_index(left)?;
let end = column_letters_to_index(right)?;
Ok((start.min(end), start.max(end)))
} else {
let column = column_letters_to_index(trimmed)?;
Ok((column, column))
}
}
pub fn is_valid_cell_reference(address: &str) -> bool {
CellAddress::parse(address).is_ok()
}
pub fn is_valid_range_reference(address: &str) -> bool {
CellRange::parse(address).is_ok()
}
pub fn is_valid_row_reference(address: &str) -> bool {
CellRange::parse(address)
.map(|range| range.is_single_row())
.unwrap_or(false)
}
pub fn is_valid_column_reference(address: &str) -> bool {
parse_column_reference(address).is_ok()
}

View File

@@ -1,357 +0,0 @@
use serde::Deserialize;
use serde::Serialize;
use crate::CellAddress;
use crate::CellRange;
use crate::SpreadsheetArtifactError;
use crate::SpreadsheetSheet;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum SpreadsheetChartType {
Area,
Bar,
Doughnut,
Line,
Pie,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum SpreadsheetChartLegendPosition {
Bottom,
Top,
Left,
Right,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SpreadsheetChartLegend {
pub visible: bool,
pub position: SpreadsheetChartLegendPosition,
pub overlay: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SpreadsheetChartAxis {
pub linked_number_format: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SpreadsheetChartSeries {
pub id: u32,
pub name: Option<String>,
pub category_sheet_name: Option<String>,
pub category_range: String,
pub value_sheet_name: Option<String>,
pub value_range: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SpreadsheetChart {
pub id: u32,
pub chart_type: SpreadsheetChartType,
pub source_sheet_name: Option<String>,
pub source_range: Option<String>,
pub title: Option<String>,
pub style_index: u32,
pub display_blanks_as: String,
pub legend: SpreadsheetChartLegend,
pub category_axis: SpreadsheetChartAxis,
pub value_axis: SpreadsheetChartAxis,
#[serde(default)]
pub series: Vec<SpreadsheetChartSeries>,
}
#[derive(Debug, Clone, Default)]
pub struct SpreadsheetChartLookup {
pub id: Option<u32>,
pub index: Option<usize>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct SpreadsheetChartCreateOptions {
pub id: Option<u32>,
pub title: Option<String>,
pub legend_visible: Option<bool>,
pub legend_position: Option<SpreadsheetChartLegendPosition>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct SpreadsheetChartProperties {
pub title: Option<String>,
pub legend_visible: Option<bool>,
pub legend_position: Option<SpreadsheetChartLegendPosition>,
}
impl SpreadsheetSheet {
pub fn list_charts(
&self,
range: Option<&CellRange>,
) -> Result<Vec<SpreadsheetChart>, SpreadsheetArtifactError> {
Ok(self
.charts
.iter()
.filter(|chart| {
range.is_none_or(|target| {
chart
.source_range
.as_deref()
.map(CellRange::parse)
.transpose()
.ok()
.flatten()
.is_some_and(|chart_range| chart_range.intersects(target))
})
})
.cloned()
.collect())
}
pub fn get_chart(
&self,
action: &str,
lookup: SpreadsheetChartLookup,
) -> Result<&SpreadsheetChart, SpreadsheetArtifactError> {
if let Some(id) = lookup.id {
return self
.charts
.iter()
.find(|chart| chart.id == id)
.ok_or_else(|| SpreadsheetArtifactError::InvalidArgs {
action: action.to_string(),
message: format!("chart id `{id}` was not found"),
});
}
if let Some(index) = lookup.index {
return self.charts.get(index).ok_or_else(|| {
SpreadsheetArtifactError::IndexOutOfRange {
action: action.to_string(),
index,
len: self.charts.len(),
}
});
}
Err(SpreadsheetArtifactError::InvalidArgs {
action: action.to_string(),
message: "chart id or index is required".to_string(),
})
}
pub fn create_chart(
&mut self,
action: &str,
chart_type: SpreadsheetChartType,
source_sheet_name: Option<String>,
source_range: &CellRange,
options: SpreadsheetChartCreateOptions,
) -> Result<u32, SpreadsheetArtifactError> {
if source_range.width() < 2 {
return Err(SpreadsheetArtifactError::InvalidArgs {
action: action.to_string(),
message: "chart source range must include at least two columns".to_string(),
});
}
let id = if let Some(id) = options.id {
if self.charts.iter().any(|chart| chart.id == id) {
return Err(SpreadsheetArtifactError::InvalidArgs {
action: action.to_string(),
message: format!("chart id `{id}` already exists"),
});
}
id
} else {
self.charts.iter().map(|chart| chart.id).max().unwrap_or(0) + 1
};
let series = (source_range.start.column + 1..=source_range.end.column)
.enumerate()
.map(|(index, value_column)| SpreadsheetChartSeries {
id: index as u32 + 1,
name: None,
category_sheet_name: source_sheet_name.clone(),
category_range: CellRange::from_start_end(
source_range.start,
CellAddress {
column: source_range.start.column,
row: source_range.end.row,
},
)
.to_a1(),
value_sheet_name: source_sheet_name.clone(),
value_range: CellRange::from_start_end(
CellAddress {
column: value_column,
row: source_range.start.row,
},
CellAddress {
column: value_column,
row: source_range.end.row,
},
)
.to_a1(),
})
.collect::<Vec<_>>();
self.charts.push(SpreadsheetChart {
id,
chart_type,
source_sheet_name,
source_range: Some(source_range.to_a1()),
title: options.title,
style_index: 102,
display_blanks_as: "gap".to_string(),
legend: SpreadsheetChartLegend {
visible: options.legend_visible.unwrap_or(true),
position: options
.legend_position
.unwrap_or(SpreadsheetChartLegendPosition::Bottom),
overlay: false,
},
category_axis: SpreadsheetChartAxis {
linked_number_format: true,
},
value_axis: SpreadsheetChartAxis {
linked_number_format: true,
},
series,
});
Ok(id)
}
pub fn add_chart_series(
&mut self,
action: &str,
lookup: SpreadsheetChartLookup,
mut series: SpreadsheetChartSeries,
) -> Result<u32, SpreadsheetArtifactError> {
validate_chart_series(action, &series)?;
let chart = self.get_chart_mut(action, lookup)?;
let next_id = chart.series.iter().map(|entry| entry.id).max().unwrap_or(0) + 1;
series.id = next_id;
chart.series.push(series);
Ok(next_id)
}
pub fn delete_chart(
&mut self,
action: &str,
lookup: SpreadsheetChartLookup,
) -> Result<(), SpreadsheetArtifactError> {
let index = if let Some(id) = lookup.id {
self.charts
.iter()
.position(|chart| chart.id == id)
.ok_or_else(|| SpreadsheetArtifactError::InvalidArgs {
action: action.to_string(),
message: format!("chart id `{id}` was not found"),
})?
} else if let Some(index) = lookup.index {
if index >= self.charts.len() {
return Err(SpreadsheetArtifactError::IndexOutOfRange {
action: action.to_string(),
index,
len: self.charts.len(),
});
}
index
} else {
return Err(SpreadsheetArtifactError::InvalidArgs {
action: action.to_string(),
message: "chart id or index is required".to_string(),
});
};
self.charts.remove(index);
Ok(())
}
pub fn set_chart_properties(
&mut self,
action: &str,
lookup: SpreadsheetChartLookup,
properties: SpreadsheetChartProperties,
) -> Result<(), SpreadsheetArtifactError> {
let chart = self.get_chart_mut(action, lookup)?;
if let Some(title) = properties.title {
chart.title = Some(title);
}
if let Some(visible) = properties.legend_visible {
chart.legend.visible = visible;
}
if let Some(position) = properties.legend_position {
chart.legend.position = position;
}
Ok(())
}
pub fn validate_charts(&self, action: &str) -> Result<(), SpreadsheetArtifactError> {
for chart in &self.charts {
if let Some(source_range) = &chart.source_range {
let range = CellRange::parse(source_range)?;
if range.width() < 2 {
return Err(SpreadsheetArtifactError::InvalidArgs {
action: action.to_string(),
message: format!(
"chart `{}` source range `{source_range}` is too narrow",
chart.id
),
});
}
}
for series in &chart.series {
validate_chart_series(action, series)?;
}
}
Ok(())
}
fn get_chart_mut(
&mut self,
action: &str,
lookup: SpreadsheetChartLookup,
) -> Result<&mut SpreadsheetChart, SpreadsheetArtifactError> {
if let Some(id) = lookup.id {
return self
.charts
.iter_mut()
.find(|chart| chart.id == id)
.ok_or_else(|| SpreadsheetArtifactError::InvalidArgs {
action: action.to_string(),
message: format!("chart id `{id}` was not found"),
});
}
if let Some(index) = lookup.index {
let len = self.charts.len();
return self.charts.get_mut(index).ok_or_else(|| {
SpreadsheetArtifactError::IndexOutOfRange {
action: action.to_string(),
index,
len,
}
});
}
Err(SpreadsheetArtifactError::InvalidArgs {
action: action.to_string(),
message: "chart id or index is required".to_string(),
})
}
}
fn validate_chart_series(
action: &str,
series: &SpreadsheetChartSeries,
) -> Result<(), SpreadsheetArtifactError> {
let category_range = CellRange::parse(&series.category_range)?;
let value_range = CellRange::parse(&series.value_range)?;
if !category_range.is_single_column() || !value_range.is_single_column() {
return Err(SpreadsheetArtifactError::InvalidArgs {
action: action.to_string(),
message: "chart category and value ranges must be single-column ranges".to_string(),
});
}
if category_range.height() != value_range.height() {
return Err(SpreadsheetArtifactError::InvalidArgs {
action: action.to_string(),
message: "chart category and value series lengths must match".to_string(),
});
}
Ok(())
}

View File

@@ -1,308 +0,0 @@
use serde::Deserialize;
use serde::Serialize;
use crate::CellRange;
use crate::SpreadsheetArtifact;
use crate::SpreadsheetArtifactError;
use crate::SpreadsheetSheet;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum SpreadsheetConditionalFormatType {
Expression,
CellIs,
ColorScale,
DataBar,
IconSet,
Top10,
UniqueValues,
DuplicateValues,
ContainsText,
NotContainsText,
BeginsWith,
EndsWith,
ContainsBlanks,
NotContainsBlanks,
ContainsErrors,
NotContainsErrors,
TimePeriod,
AboveAverage,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SpreadsheetColorScale {
pub min_type: Option<String>,
pub mid_type: Option<String>,
pub max_type: Option<String>,
pub min_value: Option<String>,
pub mid_value: Option<String>,
pub max_value: Option<String>,
pub min_color: String,
pub mid_color: Option<String>,
pub max_color: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SpreadsheetDataBar {
pub color: String,
pub min_length: Option<u8>,
pub max_length: Option<u8>,
pub show_value: Option<bool>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SpreadsheetIconSet {
pub style: String,
pub show_value: Option<bool>,
pub reverse_order: Option<bool>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SpreadsheetConditionalFormat {
pub id: u32,
pub range: String,
pub rule_type: SpreadsheetConditionalFormatType,
pub operator: Option<String>,
#[serde(default)]
pub formulas: Vec<String>,
pub text: Option<String>,
pub dxf_id: Option<u32>,
pub stop_if_true: bool,
pub priority: u32,
pub rank: Option<u32>,
pub percent: Option<bool>,
pub time_period: Option<String>,
pub above_average: Option<bool>,
pub equal_average: Option<bool>,
pub color_scale: Option<SpreadsheetColorScale>,
pub data_bar: Option<SpreadsheetDataBar>,
pub icon_set: Option<SpreadsheetIconSet>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SpreadsheetConditionalFormatCollection {
pub sheet_name: String,
pub range: String,
}
impl SpreadsheetConditionalFormatCollection {
pub fn new(sheet_name: String, range: &CellRange) -> Self {
Self {
sheet_name,
range: range.to_a1(),
}
}
pub fn range(&self) -> Result<CellRange, SpreadsheetArtifactError> {
CellRange::parse(&self.range)
}
pub fn list(
&self,
artifact: &SpreadsheetArtifact,
) -> Result<Vec<SpreadsheetConditionalFormat>, SpreadsheetArtifactError> {
let sheet = artifact.sheet_lookup(
"conditional_format_collection",
Some(&self.sheet_name),
None,
)?;
Ok(sheet.list_conditional_formats(Some(&self.range()?)))
}
pub fn add(
&self,
artifact: &mut SpreadsheetArtifact,
mut format: SpreadsheetConditionalFormat,
) -> Result<u32, SpreadsheetArtifactError> {
format.range = self.range.clone();
artifact.add_conditional_format("conditional_format_collection", &self.sheet_name, format)
}
pub fn delete(
&self,
artifact: &mut SpreadsheetArtifact,
id: u32,
) -> Result<(), SpreadsheetArtifactError> {
artifact.delete_conditional_format("conditional_format_collection", &self.sheet_name, id)
}
}
impl SpreadsheetArtifact {
pub fn validate_conditional_formats(
&self,
action: &str,
sheet_name: &str,
) -> Result<(), SpreadsheetArtifactError> {
let sheet = self.sheet_lookup(action, Some(sheet_name), None)?;
for format in &sheet.conditional_formats {
validate_conditional_format(self, format, action)?;
}
Ok(())
}
pub fn add_conditional_format(
&mut self,
action: &str,
sheet_name: &str,
mut format: SpreadsheetConditionalFormat,
) -> Result<u32, SpreadsheetArtifactError> {
validate_conditional_format(self, &format, action)?;
let sheet = self.sheet_lookup_mut(action, Some(sheet_name), None)?;
let next_id = sheet
.conditional_formats
.iter()
.map(|entry| entry.id)
.max()
.unwrap_or(0)
+ 1;
format.id = next_id;
format.priority = if format.priority == 0 {
next_id
} else {
format.priority
};
sheet.conditional_formats.push(format);
Ok(next_id)
}
pub fn delete_conditional_format(
&mut self,
action: &str,
sheet_name: &str,
id: u32,
) -> Result<(), SpreadsheetArtifactError> {
let sheet = self.sheet_lookup_mut(action, Some(sheet_name), None)?;
let previous_len = sheet.conditional_formats.len();
sheet.conditional_formats.retain(|entry| entry.id != id);
if sheet.conditional_formats.len() == previous_len {
return Err(SpreadsheetArtifactError::InvalidArgs {
action: action.to_string(),
message: format!("conditional format `{id}` was not found"),
});
}
Ok(())
}
}
impl SpreadsheetSheet {
pub fn conditional_format_collection(
&self,
range: &CellRange,
) -> SpreadsheetConditionalFormatCollection {
SpreadsheetConditionalFormatCollection::new(self.name.clone(), range)
}
pub fn list_conditional_formats(
&self,
range: Option<&CellRange>,
) -> Vec<SpreadsheetConditionalFormat> {
self.conditional_formats
.iter()
.filter(|entry| {
range.is_none_or(|target| {
CellRange::parse(&entry.range)
.map(|entry_range| entry_range.intersects(target))
.unwrap_or(false)
})
})
.cloned()
.collect()
}
}
fn validate_conditional_format(
artifact: &SpreadsheetArtifact,
format: &SpreadsheetConditionalFormat,
action: &str,
) -> Result<(), SpreadsheetArtifactError> {
CellRange::parse(&format.range)?;
if let Some(dxf_id) = format.dxf_id
&& artifact.get_differential_format(dxf_id).is_none()
{
return Err(SpreadsheetArtifactError::InvalidArgs {
action: action.to_string(),
message: format!("differential format `{dxf_id}` was not found"),
});
}
let has_style = format.dxf_id.is_some();
let has_intrinsic_visual =
format.color_scale.is_some() || format.data_bar.is_some() || format.icon_set.is_some();
match format.rule_type {
SpreadsheetConditionalFormatType::Expression | SpreadsheetConditionalFormatType::CellIs => {
if format.formulas.is_empty() {
return Err(SpreadsheetArtifactError::InvalidArgs {
action: action.to_string(),
message: "conditional format formulas are required".to_string(),
});
}
}
SpreadsheetConditionalFormatType::ContainsText
| SpreadsheetConditionalFormatType::NotContainsText
| SpreadsheetConditionalFormatType::BeginsWith
| SpreadsheetConditionalFormatType::EndsWith => {
if format.text.as_deref().unwrap_or_default().is_empty() {
return Err(SpreadsheetArtifactError::InvalidArgs {
action: action.to_string(),
message: "conditional format text is required".to_string(),
});
}
}
SpreadsheetConditionalFormatType::ColorScale => {
if format.color_scale.is_none() {
return Err(SpreadsheetArtifactError::InvalidArgs {
action: action.to_string(),
message: "color scale settings are required".to_string(),
});
}
}
SpreadsheetConditionalFormatType::DataBar => {
if format.data_bar.is_none() {
return Err(SpreadsheetArtifactError::InvalidArgs {
action: action.to_string(),
message: "data bar settings are required".to_string(),
});
}
}
SpreadsheetConditionalFormatType::IconSet => {
if format.icon_set.is_none() {
return Err(SpreadsheetArtifactError::InvalidArgs {
action: action.to_string(),
message: "icon set settings are required".to_string(),
});
}
}
SpreadsheetConditionalFormatType::Top10 => {
if format.rank.is_none() {
return Err(SpreadsheetArtifactError::InvalidArgs {
action: action.to_string(),
message: "top10 rank is required".to_string(),
});
}
}
SpreadsheetConditionalFormatType::TimePeriod => {
if format.time_period.as_deref().unwrap_or_default().is_empty() {
return Err(SpreadsheetArtifactError::InvalidArgs {
action: action.to_string(),
message: "time period is required".to_string(),
});
}
}
SpreadsheetConditionalFormatType::AboveAverage => {}
SpreadsheetConditionalFormatType::UniqueValues
| SpreadsheetConditionalFormatType::DuplicateValues
| SpreadsheetConditionalFormatType::ContainsBlanks
| SpreadsheetConditionalFormatType::NotContainsBlanks
| SpreadsheetConditionalFormatType::ContainsErrors
| SpreadsheetConditionalFormatType::NotContainsErrors => {}
}
if !has_style && !has_intrinsic_visual {
return Err(SpreadsheetArtifactError::InvalidArgs {
action: action.to_string(),
message: "conditional formatting requires at least one style component".to_string(),
});
}
Ok(())
}

View File

@@ -1,39 +0,0 @@
use std::path::PathBuf;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum SpreadsheetArtifactError {
#[error("missing `artifact_id` for action `{action}`")]
MissingArtifactId { action: String },
#[error("unknown artifact id `{artifact_id}` for action `{action}`")]
UnknownArtifactId { action: String, artifact_id: String },
#[error("unknown action `{0}`")]
UnknownAction(String),
#[error("invalid args for action `{action}`: {message}")]
InvalidArgs { action: String, message: String },
#[error("invalid address `{address}`: {message}")]
InvalidAddress { address: String, message: String },
#[error("sheet lookup failed for action `{action}`: {message}")]
SheetLookup { action: String, message: String },
#[error("index `{index}` is out of range for action `{action}`; len={len}")]
IndexOutOfRange {
action: String,
index: usize,
len: usize,
},
#[error("merge conflict for action `{action}` on range `{range}` with `{conflict}`")]
MergeConflict {
action: String,
range: String,
conflict: String,
},
#[error("formula error at `{location}`: {message}")]
Formula { location: String, message: String },
#[error("serialization failed: {message}")]
Serialization { message: String },
#[error("failed to import XLSX `{path}`: {message}")]
ImportFailed { path: PathBuf, message: String },
#[error("failed to export XLSX `{path}`: {message}")]
ExportFailed { path: PathBuf, message: String },
}

View File

@@ -1,535 +0,0 @@
use std::collections::BTreeSet;
use crate::CellAddress;
use crate::CellRange;
use crate::SpreadsheetArtifact;
use crate::SpreadsheetArtifactError;
use crate::SpreadsheetCellValue;
#[derive(Debug, Clone)]
enum Token {
Number(f64),
Cell(String),
Ident(String),
Plus,
Minus,
Star,
Slash,
LParen,
RParen,
Colon,
Comma,
}
#[derive(Debug, Clone)]
enum Expr {
Number(f64),
Cell(CellAddress),
Range(CellRange),
UnaryMinus(Box<Expr>),
Binary {
op: BinaryOp,
left: Box<Expr>,
right: Box<Expr>,
},
Function {
name: String,
args: Vec<Expr>,
},
}
#[derive(Debug, Clone, Copy)]
enum BinaryOp {
Add,
Subtract,
Multiply,
Divide,
}
#[derive(Debug, Clone)]
enum EvalValue {
Scalar(Option<SpreadsheetCellValue>),
Range(Vec<Option<SpreadsheetCellValue>>),
}
pub(crate) fn recalculate_workbook(artifact: &mut SpreadsheetArtifact) {
let updates = artifact
.sheets
.iter()
.enumerate()
.flat_map(|(sheet_index, sheet)| {
sheet.cells.iter().filter_map(move |(address, cell)| {
cell.formula
.as_ref()
.map(|formula| (sheet_index, *address, formula.clone()))
})
})
.map(|(sheet_index, address, formula)| {
let mut stack = BTreeSet::new();
let value = evaluate_formula(artifact, sheet_index, &formula, &mut stack)
.unwrap_or_else(|error| {
Some(SpreadsheetCellValue::Error(map_error_to_code(&error)))
});
(sheet_index, address, value)
})
.collect::<Vec<_>>();
for (sheet_index, address, value) in updates {
if let Some(sheet) = artifact.sheets.get_mut(sheet_index)
&& let Some(cell) = sheet.cells.get_mut(&address)
{
cell.value = value;
}
}
}
fn evaluate_formula(
artifact: &SpreadsheetArtifact,
sheet_index: usize,
formula: &str,
stack: &mut BTreeSet<(usize, CellAddress)>,
) -> Result<Option<SpreadsheetCellValue>, SpreadsheetArtifactError> {
let source = formula.trim().trim_start_matches('=');
let tokens = tokenize(source)?;
let mut parser = Parser::new(tokens);
let expr = parser.parse_expression()?;
if parser.has_remaining() {
return Err(SpreadsheetArtifactError::Formula {
location: formula.to_string(),
message: "unexpected trailing tokens".to_string(),
});
}
match evaluate_expr(artifact, sheet_index, &expr, stack)? {
EvalValue::Scalar(value) => Ok(value),
EvalValue::Range(_) => Err(SpreadsheetArtifactError::Formula {
location: formula.to_string(),
message: "range expressions are only allowed inside functions".to_string(),
}),
}
}
fn evaluate_expr(
artifact: &SpreadsheetArtifact,
sheet_index: usize,
expr: &Expr,
stack: &mut BTreeSet<(usize, CellAddress)>,
) -> Result<EvalValue, SpreadsheetArtifactError> {
match expr {
Expr::Number(value) => Ok(EvalValue::Scalar(Some(number_to_value(*value)))),
Expr::Cell(address) => evaluate_cell_reference(artifact, sheet_index, *address, stack),
Expr::Range(range) => {
let sheet = artifact.sheets.get(sheet_index).ok_or_else(|| {
SpreadsheetArtifactError::Formula {
location: range.to_a1(),
message: "sheet index was not found".to_string(),
}
})?;
let values = range
.addresses()
.map(|address| sheet.get_cell(address).and_then(|cell| cell.value.clone()))
.collect::<Vec<_>>();
Ok(EvalValue::Range(values))
}
Expr::UnaryMinus(inner) => {
let value = evaluate_scalar(artifact, sheet_index, inner, stack)?;
Ok(EvalValue::Scalar(match value {
None => Some(SpreadsheetCellValue::Integer(0)),
Some(SpreadsheetCellValue::Integer(value)) => {
Some(SpreadsheetCellValue::Integer(-value))
}
Some(SpreadsheetCellValue::Float(value)) => {
Some(SpreadsheetCellValue::Float(-value))
}
Some(SpreadsheetCellValue::Error(value)) => {
Some(SpreadsheetCellValue::Error(value))
}
Some(_) => Some(SpreadsheetCellValue::Error("#VALUE!".to_string())),
}))
}
Expr::Binary { op, left, right } => {
let left = evaluate_scalar(artifact, sheet_index, left, stack)?;
let right = evaluate_scalar(artifact, sheet_index, right, stack)?;
Ok(EvalValue::Scalar(Some(apply_binary_op(*op, left, right)?)))
}
Expr::Function { name, args } => {
let mut numeric = Vec::new();
for arg in args {
match evaluate_expr(artifact, sheet_index, arg, stack)? {
EvalValue::Scalar(value) => {
if let Some(number) = scalar_to_number(value.clone())? {
numeric.push(number);
}
}
EvalValue::Range(values) => {
for value in values {
if let Some(number) = scalar_to_number(value.clone())? {
numeric.push(number);
}
}
}
}
}
let upper = name.to_ascii_uppercase();
let result = match upper.as_str() {
"SUM" => numeric.iter().sum::<f64>(),
"AVERAGE" => {
if numeric.is_empty() {
return Ok(EvalValue::Scalar(None));
}
numeric.iter().sum::<f64>() / numeric.len() as f64
}
"MIN" => numeric.iter().copied().reduce(f64::min).unwrap_or(0.0),
"MAX" => numeric.iter().copied().reduce(f64::max).unwrap_or(0.0),
_ => {
return Ok(EvalValue::Scalar(Some(SpreadsheetCellValue::Error(
"#NAME?".to_string(),
))));
}
};
Ok(EvalValue::Scalar(Some(number_to_value(result))))
}
}
}
fn evaluate_scalar(
artifact: &SpreadsheetArtifact,
sheet_index: usize,
expr: &Expr,
stack: &mut BTreeSet<(usize, CellAddress)>,
) -> Result<Option<SpreadsheetCellValue>, SpreadsheetArtifactError> {
match evaluate_expr(artifact, sheet_index, expr, stack)? {
EvalValue::Scalar(value) => Ok(value),
EvalValue::Range(_) => Err(SpreadsheetArtifactError::Formula {
location: format!("{expr:?}"),
message: "expected a scalar expression".to_string(),
}),
}
}
fn evaluate_cell_reference(
artifact: &SpreadsheetArtifact,
sheet_index: usize,
address: CellAddress,
stack: &mut BTreeSet<(usize, CellAddress)>,
) -> Result<EvalValue, SpreadsheetArtifactError> {
let Some(sheet) = artifact.sheets.get(sheet_index) else {
return Err(SpreadsheetArtifactError::Formula {
location: address.to_a1(),
message: "sheet index was not found".to_string(),
});
};
let key = (sheet_index, address);
if !stack.insert(key) {
return Ok(EvalValue::Scalar(Some(SpreadsheetCellValue::Error(
"#CYCLE!".to_string(),
))));
}
let value = if let Some(cell) = sheet.get_cell(address) {
if let Some(formula) = &cell.formula {
evaluate_formula(artifact, sheet_index, formula, stack)?
} else {
cell.value.clone()
}
} else {
None
};
stack.remove(&key);
Ok(EvalValue::Scalar(value))
}
fn apply_binary_op(
op: BinaryOp,
left: Option<SpreadsheetCellValue>,
right: Option<SpreadsheetCellValue>,
) -> Result<SpreadsheetCellValue, SpreadsheetArtifactError> {
if let Some(SpreadsheetCellValue::Error(value)) = &left {
return Ok(SpreadsheetCellValue::Error(value.clone()));
}
if let Some(SpreadsheetCellValue::Error(value)) = &right {
return Ok(SpreadsheetCellValue::Error(value.clone()));
}
let left = scalar_to_number(left)?;
let right = scalar_to_number(right)?;
let left = left.unwrap_or(0.0);
let right = right.unwrap_or(0.0);
let result = match op {
BinaryOp::Add => left + right,
BinaryOp::Subtract => left - right,
BinaryOp::Multiply => left * right,
BinaryOp::Divide => {
if right == 0.0 {
return Ok(SpreadsheetCellValue::Error("#DIV/0!".to_string()));
}
left / right
}
};
Ok(number_to_value(result))
}
fn scalar_to_number(
value: Option<SpreadsheetCellValue>,
) -> Result<Option<f64>, SpreadsheetArtifactError> {
match value {
None => Ok(None),
Some(SpreadsheetCellValue::Integer(value)) => Ok(Some(value as f64)),
Some(SpreadsheetCellValue::Float(value)) => Ok(Some(value)),
Some(SpreadsheetCellValue::Bool(value)) => Ok(Some(if value { 1.0 } else { 0.0 })),
Some(SpreadsheetCellValue::Error(value)) => Err(SpreadsheetArtifactError::Formula {
location: value,
message: "encountered error value".to_string(),
}),
Some(other) => Err(SpreadsheetArtifactError::Formula {
location: format!("{other:?}"),
message: "value is not numeric".to_string(),
}),
}
}
fn number_to_value(number: f64) -> SpreadsheetCellValue {
if number.fract() == 0.0 {
SpreadsheetCellValue::Integer(number as i64)
} else {
SpreadsheetCellValue::Float(number)
}
}
fn map_error_to_code(error: &SpreadsheetArtifactError) -> String {
match error {
SpreadsheetArtifactError::Formula { message, .. } => {
if message.contains("cycle") {
"#CYCLE!".to_string()
} else if message.contains("not numeric") || message.contains("scalar") {
"#VALUE!".to_string()
} else {
"#ERROR!".to_string()
}
}
SpreadsheetArtifactError::InvalidAddress { .. } => "#REF!".to_string(),
_ => "#ERROR!".to_string(),
}
}
fn tokenize(source: &str) -> Result<Vec<Token>, SpreadsheetArtifactError> {
let chars = source.chars().collect::<Vec<_>>();
let mut index = 0usize;
let mut tokens = Vec::new();
while index < chars.len() {
let ch = chars[index];
if ch.is_ascii_whitespace() {
index += 1;
continue;
}
match ch {
'+' => {
tokens.push(Token::Plus);
index += 1;
}
'-' => {
tokens.push(Token::Minus);
index += 1;
}
'*' => {
tokens.push(Token::Star);
index += 1;
}
'/' => {
tokens.push(Token::Slash);
index += 1;
}
'(' => {
tokens.push(Token::LParen);
index += 1;
}
')' => {
tokens.push(Token::RParen);
index += 1;
}
':' => {
tokens.push(Token::Colon);
index += 1;
}
',' => {
tokens.push(Token::Comma);
index += 1;
}
'0'..='9' | '.' => {
let start = index;
index += 1;
while index < chars.len() && (chars[index].is_ascii_digit() || chars[index] == '.')
{
index += 1;
}
let number = source[start..index].parse::<f64>().map_err(|_| {
SpreadsheetArtifactError::Formula {
location: source.to_string(),
message: "invalid numeric literal".to_string(),
}
})?;
tokens.push(Token::Number(number));
}
'A'..='Z' | 'a'..='z' | '_' => {
let start = index;
index += 1;
while index < chars.len()
&& (chars[index].is_ascii_alphanumeric() || chars[index] == '_')
{
index += 1;
}
let text = source[start..index].to_string();
if text.chars().any(|part| part.is_ascii_digit())
&& text.chars().any(|part| part.is_ascii_alphabetic())
{
tokens.push(Token::Cell(text));
} else {
tokens.push(Token::Ident(text));
}
}
other => {
return Err(SpreadsheetArtifactError::Formula {
location: source.to_string(),
message: format!("unsupported token `{other}`"),
});
}
}
}
Ok(tokens)
}
struct Parser {
tokens: Vec<Token>,
index: usize,
}
impl Parser {
fn new(tokens: Vec<Token>) -> Self {
Self { tokens, index: 0 }
}
fn has_remaining(&self) -> bool {
self.index < self.tokens.len()
}
fn parse_expression(&mut self) -> Result<Expr, SpreadsheetArtifactError> {
let mut expr = self.parse_term()?;
while let Some(token) = self.peek() {
let op = match token {
Token::Plus => BinaryOp::Add,
Token::Minus => BinaryOp::Subtract,
_ => break,
};
self.index += 1;
let right = self.parse_term()?;
expr = Expr::Binary {
op,
left: Box::new(expr),
right: Box::new(right),
};
}
Ok(expr)
}
fn parse_term(&mut self) -> Result<Expr, SpreadsheetArtifactError> {
let mut expr = self.parse_factor()?;
while let Some(token) = self.peek() {
let op = match token {
Token::Star => BinaryOp::Multiply,
Token::Slash => BinaryOp::Divide,
_ => break,
};
self.index += 1;
let right = self.parse_factor()?;
expr = Expr::Binary {
op,
left: Box::new(expr),
right: Box::new(right),
};
}
Ok(expr)
}
fn parse_factor(&mut self) -> Result<Expr, SpreadsheetArtifactError> {
match self.peek() {
Some(Token::Minus) => {
self.index += 1;
Ok(Expr::UnaryMinus(Box::new(self.parse_factor()?)))
}
_ => self.parse_primary(),
}
}
fn parse_primary(&mut self) -> Result<Expr, SpreadsheetArtifactError> {
match self.next().cloned() {
Some(Token::Number(value)) => Ok(Expr::Number(value)),
Some(Token::Cell(address)) => {
let start = CellAddress::parse(&address)?;
if matches!(self.peek(), Some(Token::Colon)) {
self.index += 1;
let Some(Token::Cell(end)) = self.next().cloned() else {
return Err(SpreadsheetArtifactError::Formula {
location: address,
message: "expected cell after `:`".to_string(),
});
};
Ok(Expr::Range(CellRange::from_start_end(
start,
CellAddress::parse(&end)?,
)))
} else {
Ok(Expr::Cell(start))
}
}
Some(Token::Ident(name)) => {
if !matches!(self.next(), Some(Token::LParen)) {
return Err(SpreadsheetArtifactError::Formula {
location: name,
message: "expected `(` after function name".to_string(),
});
}
let mut args = Vec::new();
if !matches!(self.peek(), Some(Token::RParen)) {
loop {
args.push(self.parse_expression()?);
if matches!(self.peek(), Some(Token::Comma)) {
self.index += 1;
continue;
}
break;
}
}
if !matches!(self.next(), Some(Token::RParen)) {
return Err(SpreadsheetArtifactError::Formula {
location: name,
message: "expected `)`".to_string(),
});
}
Ok(Expr::Function { name, args })
}
Some(Token::LParen) => {
let expr = self.parse_expression()?;
if !matches!(self.next(), Some(Token::RParen)) {
return Err(SpreadsheetArtifactError::Formula {
location: format!("{expr:?}"),
message: "expected `)`".to_string(),
});
}
Ok(expr)
}
other => Err(SpreadsheetArtifactError::Formula {
location: format!("{other:?}"),
message: "unexpected token".to_string(),
}),
}
}
fn peek(&self) -> Option<&Token> {
self.tokens.get(self.index)
}
fn next(&mut self) -> Option<&Token> {
let token = self.tokens.get(self.index);
self.index += usize::from(token.is_some());
token
}
}

View File

@@ -1,26 +0,0 @@
mod address;
mod chart;
mod conditional;
mod error;
mod formula;
mod manager;
mod model;
mod pivot;
mod render;
mod style;
mod table;
mod xlsx;
#[cfg(test)]
mod tests;
pub use address::*;
pub use chart::*;
pub use conditional::*;
pub use error::*;
pub use manager::*;
pub use model::*;
pub use pivot::*;
pub use render::*;
pub use style::*;
pub use table::*;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,177 +0,0 @@
use std::collections::BTreeMap;
use serde::Deserialize;
use serde::Serialize;
use crate::CellRange;
use crate::SpreadsheetArtifactError;
use crate::SpreadsheetCellRangeRef;
use crate::SpreadsheetSheet;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SpreadsheetPivotFieldItem {
pub item_type: Option<String>,
pub index: Option<u32>,
pub hidden: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SpreadsheetPivotField {
pub index: u32,
pub name: Option<String>,
pub axis: Option<String>,
#[serde(default)]
pub items: Vec<SpreadsheetPivotFieldItem>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SpreadsheetPivotFieldReference {
pub field_index: u32,
pub field_name: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SpreadsheetPivotPageField {
pub field_index: u32,
pub field_name: Option<String>,
pub selected_item: Option<u32>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SpreadsheetPivotDataField {
pub field_index: u32,
pub field_name: Option<String>,
pub name: Option<String>,
pub subtotal: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SpreadsheetPivotFilter {
pub field_index: Option<u32>,
pub field_name: Option<String>,
pub filter_type: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SpreadsheetPivotTable {
pub name: String,
pub cache_id: u32,
pub address: Option<String>,
#[serde(default)]
pub row_fields: Vec<SpreadsheetPivotFieldReference>,
#[serde(default)]
pub column_fields: Vec<SpreadsheetPivotFieldReference>,
#[serde(default)]
pub page_fields: Vec<SpreadsheetPivotPageField>,
#[serde(default)]
pub data_fields: Vec<SpreadsheetPivotDataField>,
#[serde(default)]
pub filters: Vec<SpreadsheetPivotFilter>,
#[serde(default)]
pub pivot_fields: Vec<SpreadsheetPivotField>,
pub style_name: Option<String>,
pub part_path: Option<String>,
}
#[derive(Debug, Clone, Default)]
pub struct SpreadsheetPivotTableLookup<'a> {
pub name: Option<&'a str>,
pub index: Option<usize>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SpreadsheetPivotCacheDefinition {
pub definition_path: String,
#[serde(default)]
pub field_names: Vec<Option<String>>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct SpreadsheetPivotPreservation {
#[serde(default)]
pub caches: BTreeMap<u32, SpreadsheetPivotCacheDefinition>,
#[serde(default)]
pub parts: BTreeMap<String, String>,
}
impl SpreadsheetPivotTable {
pub fn range(&self) -> Result<Option<CellRange>, SpreadsheetArtifactError> {
self.address.as_deref().map(CellRange::parse).transpose()
}
pub fn range_ref(
&self,
sheet_name: &str,
) -> Result<Option<SpreadsheetCellRangeRef>, SpreadsheetArtifactError> {
Ok(self
.range()?
.map(|range| SpreadsheetCellRangeRef::new(sheet_name.to_string(), &range)))
}
}
impl SpreadsheetSheet {
pub fn list_pivot_tables(
&self,
range: Option<&CellRange>,
) -> Result<Vec<SpreadsheetPivotTable>, SpreadsheetArtifactError> {
Ok(self
.pivot_tables
.iter()
.filter(|pivot_table| {
range.is_none_or(|target| {
pivot_table
.range()
.ok()
.flatten()
.is_some_and(|pivot_range| pivot_range.intersects(target))
})
})
.cloned()
.collect())
}
pub fn get_pivot_table(
&self,
action: &str,
lookup: SpreadsheetPivotTableLookup,
) -> Result<&SpreadsheetPivotTable, SpreadsheetArtifactError> {
if let Some(name) = lookup.name {
return self
.pivot_tables
.iter()
.find(|pivot_table| pivot_table.name == name)
.ok_or_else(|| SpreadsheetArtifactError::InvalidArgs {
action: action.to_string(),
message: format!("pivot table `{name}` was not found"),
});
}
if let Some(index) = lookup.index {
return self.pivot_tables.get(index).ok_or_else(|| {
SpreadsheetArtifactError::IndexOutOfRange {
action: action.to_string(),
index,
len: self.pivot_tables.len(),
}
});
}
Err(SpreadsheetArtifactError::InvalidArgs {
action: action.to_string(),
message: "pivot table name or index is required".to_string(),
})
}
pub fn validate_pivot_tables(&self, action: &str) -> Result<(), SpreadsheetArtifactError> {
for pivot_table in &self.pivot_tables {
if pivot_table.name.trim().is_empty() {
return Err(SpreadsheetArtifactError::InvalidArgs {
action: action.to_string(),
message: "pivot table name cannot be empty".to_string(),
});
}
if let Some(address) = &pivot_table.address {
CellRange::parse(address)?;
}
}
Ok(())
}
}

View File

@@ -1,373 +0,0 @@
use std::fs;
use std::path::Path;
use std::path::PathBuf;
use serde::Deserialize;
use serde::Serialize;
use crate::CellAddress;
use crate::CellRange;
use crate::SpreadsheetArtifact;
use crate::SpreadsheetArtifactError;
use crate::SpreadsheetSheet;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SpreadsheetRenderOptions {
pub output_path: Option<PathBuf>,
pub center_address: Option<String>,
pub width: Option<u32>,
pub height: Option<u32>,
pub include_headers: bool,
pub scale: f64,
pub performance_mode: bool,
}
impl Default for SpreadsheetRenderOptions {
fn default() -> Self {
Self {
output_path: None,
center_address: None,
width: None,
height: None,
include_headers: true,
scale: 1.0,
performance_mode: false,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SpreadsheetRenderedOutput {
pub path: PathBuf,
pub html: String,
}
impl SpreadsheetSheet {
pub fn render_html(
&self,
range: Option<&CellRange>,
options: &SpreadsheetRenderOptions,
) -> Result<String, SpreadsheetArtifactError> {
let center = options
.center_address
.as_deref()
.map(CellAddress::parse)
.transpose()?;
let viewport = render_viewport(self, range, center, options)?;
let title = range
.map(CellRange::to_a1)
.unwrap_or_else(|| self.name.clone());
Ok(format!(
concat!(
"<!doctype html><html><head><meta charset=\"utf-8\">",
"<title>{}</title>",
"<style>{}</style>",
"</head><body>",
"<section class=\"spreadsheet-preview\" data-sheet=\"{}\" data-performance-mode=\"{}\">",
"<header><h1>{}</h1><p>{}</p></header>",
"<div class=\"viewport\" style=\"{}\">",
"<table>{}</table>",
"</div></section></body></html>"
),
html_escape(&title),
preview_css(),
html_escape(&self.name),
options.performance_mode,
html_escape(&title),
html_escape(&viewport.to_a1()),
viewport_style(options),
render_table(self, &viewport, options),
))
}
}
impl SpreadsheetArtifact {
pub fn render_workbook_previews(
&self,
cwd: &Path,
options: &SpreadsheetRenderOptions,
) -> Result<Vec<SpreadsheetRenderedOutput>, SpreadsheetArtifactError> {
let sheets = if self.sheets.is_empty() {
vec![SpreadsheetSheet::new("Sheet1".to_string())]
} else {
self.sheets.clone()
};
let output_paths = workbook_output_paths(self, cwd, options, &sheets);
sheets
.iter()
.zip(output_paths)
.map(|(sheet, path)| {
let html = sheet.render_html(None, options)?;
write_rendered_output(&path, &html)?;
Ok(SpreadsheetRenderedOutput { path, html })
})
.collect()
}
pub fn render_sheet_preview(
&self,
cwd: &Path,
sheet: &SpreadsheetSheet,
options: &SpreadsheetRenderOptions,
) -> Result<SpreadsheetRenderedOutput, SpreadsheetArtifactError> {
let path = single_output_path(
cwd,
self,
options.output_path.as_deref(),
&format!("render_{}", sanitize_file_component(&sheet.name)),
);
let html = sheet.render_html(None, options)?;
write_rendered_output(&path, &html)?;
Ok(SpreadsheetRenderedOutput { path, html })
}
pub fn render_range_preview(
&self,
cwd: &Path,
sheet: &SpreadsheetSheet,
range: &CellRange,
options: &SpreadsheetRenderOptions,
) -> Result<SpreadsheetRenderedOutput, SpreadsheetArtifactError> {
let path = single_output_path(
cwd,
self,
options.output_path.as_deref(),
&format!(
"render_{}_{}",
sanitize_file_component(&sheet.name),
sanitize_file_component(&range.to_a1())
),
);
let html = sheet.render_html(Some(range), options)?;
write_rendered_output(&path, &html)?;
Ok(SpreadsheetRenderedOutput { path, html })
}
}
fn render_viewport(
sheet: &SpreadsheetSheet,
range: Option<&CellRange>,
center: Option<CellAddress>,
options: &SpreadsheetRenderOptions,
) -> Result<CellRange, SpreadsheetArtifactError> {
let base = range
.cloned()
.or_else(|| sheet.minimum_range())
.unwrap_or_else(|| {
CellRange::from_start_end(
CellAddress { column: 1, row: 1 },
CellAddress { column: 1, row: 1 },
)
});
let Some(center) = center else {
return Ok(base);
};
let visible_columns = options
.width
.map(|width| estimated_visible_count(width, 96.0, options.scale))
.unwrap_or(base.width() as u32);
let visible_rows = options
.height
.map(|height| estimated_visible_count(height, 28.0, options.scale))
.unwrap_or(base.height() as u32);
let half_columns = visible_columns / 2;
let half_rows = visible_rows / 2;
let start_column = center
.column
.saturating_sub(half_columns)
.max(base.start.column);
let start_row = center.row.saturating_sub(half_rows).max(base.start.row);
let end_column = (start_column + visible_columns.saturating_sub(1)).min(base.end.column);
let end_row = (start_row + visible_rows.saturating_sub(1)).min(base.end.row);
Ok(CellRange::from_start_end(
CellAddress {
column: start_column,
row: start_row,
},
CellAddress {
column: end_column.max(start_column),
row: end_row.max(start_row),
},
))
}
fn estimated_visible_count(dimension: u32, cell_size: f64, scale: f64) -> u32 {
((dimension as f64 / (cell_size * scale.max(0.1))).floor() as u32).max(1)
}
fn render_table(
sheet: &SpreadsheetSheet,
range: &CellRange,
options: &SpreadsheetRenderOptions,
) -> String {
let mut rows = Vec::new();
if options.include_headers {
let mut header = vec!["<tr><th class=\"corner\"></th>".to_string()];
for column in range.start.column..=range.end.column {
header.push(format!(
"<th>{}</th>",
crate::column_index_to_letters(column)
));
}
header.push("</tr>".to_string());
rows.push(header.join(""));
}
for row in range.start.row..=range.end.row {
let mut cells = Vec::new();
if options.include_headers {
cells.push(format!("<th>{row}</th>"));
}
for column in range.start.column..=range.end.column {
let address = CellAddress { column, row };
let view = sheet.get_cell_view(address);
let value = view
.data
.as_ref()
.map(render_data_value)
.unwrap_or_default();
cells.push(format!(
"<td data-address=\"{}\" data-style-index=\"{}\">{}</td>",
address.to_a1(),
view.style_index,
html_escape(&value)
));
}
rows.push(format!("<tr>{}</tr>", cells.join("")));
}
rows.join("")
}
fn render_data_value(value: &serde_json::Value) -> String {
match value {
serde_json::Value::String(value) => value.clone(),
serde_json::Value::Bool(value) => value.to_string(),
serde_json::Value::Number(value) => value.to_string(),
serde_json::Value::Null => String::new(),
other => other.to_string(),
}
}
fn viewport_style(options: &SpreadsheetRenderOptions) -> String {
let mut style = vec![
format!("--scale: {}", options.scale.max(0.1)),
format!(
"--headers: {}",
if options.include_headers { "1" } else { "0" }
),
];
if let Some(width) = options.width {
style.push(format!("width: {width}px"));
}
if let Some(height) = options.height {
style.push(format!("height: {height}px"));
}
style.push("overflow: auto".to_string());
style.join("; ")
}
fn preview_css() -> &'static str {
concat!(
"body{margin:0;padding:24px;background:#f5f3ee;color:#1e1e1e;font-family:Georgia,serif;}",
".spreadsheet-preview{display:flex;flex-direction:column;gap:16px;}",
"header h1{margin:0;font-size:24px;}header p{margin:0;color:#6b6257;font-size:13px;}",
".viewport{border:1px solid #d6d0c7;background:#fff;box-shadow:0 12px 30px rgba(0,0,0,.08);}",
"table{border-collapse:collapse;transform:scale(var(--scale));transform-origin:top left;}",
"th,td{border:1px solid #ddd3c6;padding:6px 10px;min-width:72px;max-width:240px;font-size:13px;text-align:left;vertical-align:top;}",
"th{background:#f0ebe3;font-weight:600;position:sticky;top:0;z-index:1;}",
".corner{background:#e7e0d6;left:0;z-index:2;}",
"td{white-space:pre-wrap;}"
)
}
fn write_rendered_output(path: &Path, html: &str) -> Result<(), SpreadsheetArtifactError> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|error| SpreadsheetArtifactError::ExportFailed {
path: path.to_path_buf(),
message: error.to_string(),
})?;
}
fs::write(path, html).map_err(|error| SpreadsheetArtifactError::ExportFailed {
path: path.to_path_buf(),
message: error.to_string(),
})
}
fn workbook_output_paths(
artifact: &SpreadsheetArtifact,
cwd: &Path,
options: &SpreadsheetRenderOptions,
sheets: &[SpreadsheetSheet],
) -> Vec<PathBuf> {
if let Some(output_path) = options.output_path.as_deref() {
if output_path.extension().is_some_and(|ext| ext == "html") {
let stem = output_path
.file_stem()
.and_then(|value| value.to_str())
.unwrap_or("render");
let parent = output_path.parent().unwrap_or(cwd);
return sheets
.iter()
.map(|sheet| {
parent.join(format!(
"{}_{}.html",
stem,
sanitize_file_component(&sheet.name)
))
})
.collect();
}
return sheets
.iter()
.map(|sheet| output_path.join(format!("{}.html", sanitize_file_component(&sheet.name))))
.collect();
}
sheets
.iter()
.map(|sheet| {
cwd.join(format!(
"{}_render_{}.html",
artifact.artifact_id,
sanitize_file_component(&sheet.name)
))
})
.collect()
}
fn single_output_path(
cwd: &Path,
artifact: &SpreadsheetArtifact,
output_path: Option<&Path>,
suffix: &str,
) -> PathBuf {
if let Some(output_path) = output_path {
return if output_path.extension().is_some_and(|ext| ext == "html") {
output_path.to_path_buf()
} else {
output_path.join(format!("{suffix}.html"))
};
}
cwd.join(format!("{}_{}.html", artifact.artifact_id, suffix))
}
fn sanitize_file_component(value: &str) -> String {
value
.chars()
.map(|character| {
if character.is_ascii_alphanumeric() {
character
} else {
'_'
}
})
.collect()
}
fn html_escape(value: &str) -> String {
value
.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&#39;")
}

View File

@@ -1,580 +0,0 @@
use std::collections::BTreeMap;
use serde::Deserialize;
use serde::Serialize;
use crate::CellRange;
use crate::SpreadsheetArtifact;
use crate::SpreadsheetArtifactError;
use crate::SpreadsheetCellRangeRef;
use crate::SpreadsheetSheet;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct SpreadsheetFontFace {
pub font_family: Option<String>,
pub font_scheme: Option<String>,
pub typeface: Option<String>,
}
impl SpreadsheetFontFace {
fn merge(&self, patch: &Self) -> Self {
Self {
font_family: patch
.font_family
.clone()
.or_else(|| self.font_family.clone()),
font_scheme: patch
.font_scheme
.clone()
.or_else(|| self.font_scheme.clone()),
typeface: patch.typeface.clone().or_else(|| self.typeface.clone()),
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
pub struct SpreadsheetTextStyle {
pub bold: Option<bool>,
pub italic: Option<bool>,
pub underline: Option<bool>,
pub font_size: Option<f64>,
pub font_color: Option<String>,
pub text_alignment: Option<String>,
pub anchor: Option<String>,
pub vertical_text_orientation: Option<String>,
pub text_rotation: Option<i32>,
pub paragraph_spacing: Option<bool>,
pub bottom_inset: Option<f64>,
pub left_inset: Option<f64>,
pub right_inset: Option<f64>,
pub top_inset: Option<f64>,
pub font_family: Option<String>,
pub font_scheme: Option<String>,
pub typeface: Option<String>,
pub font_face: Option<SpreadsheetFontFace>,
}
impl SpreadsheetTextStyle {
fn merge(&self, patch: &Self) -> Self {
Self {
bold: patch.bold.or(self.bold),
italic: patch.italic.or(self.italic),
underline: patch.underline.or(self.underline),
font_size: patch.font_size.or(self.font_size),
font_color: patch.font_color.clone().or_else(|| self.font_color.clone()),
text_alignment: patch
.text_alignment
.clone()
.or_else(|| self.text_alignment.clone()),
anchor: patch.anchor.clone().or_else(|| self.anchor.clone()),
vertical_text_orientation: patch
.vertical_text_orientation
.clone()
.or_else(|| self.vertical_text_orientation.clone()),
text_rotation: patch.text_rotation.or(self.text_rotation),
paragraph_spacing: patch.paragraph_spacing.or(self.paragraph_spacing),
bottom_inset: patch.bottom_inset.or(self.bottom_inset),
left_inset: patch.left_inset.or(self.left_inset),
right_inset: patch.right_inset.or(self.right_inset),
top_inset: patch.top_inset.or(self.top_inset),
font_family: patch
.font_family
.clone()
.or_else(|| self.font_family.clone()),
font_scheme: patch
.font_scheme
.clone()
.or_else(|| self.font_scheme.clone()),
typeface: patch.typeface.clone().or_else(|| self.typeface.clone()),
font_face: match (&self.font_face, &patch.font_face) {
(Some(base), Some(update)) => Some(base.merge(update)),
(None, Some(update)) => Some(update.clone()),
(Some(base), None) => Some(base.clone()),
(None, None) => None,
},
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SpreadsheetGradientStop {
pub position: f64,
pub color: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SpreadsheetFillRectangle {
pub left: f64,
pub right: f64,
pub top: f64,
pub bottom: f64,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
pub struct SpreadsheetFill {
pub solid_fill_color: Option<String>,
pub pattern_type: Option<String>,
pub pattern_foreground_color: Option<String>,
pub pattern_background_color: Option<String>,
#[serde(default)]
pub color_transforms: Vec<String>,
pub gradient_fill_type: Option<String>,
#[serde(default)]
pub gradient_stops: Vec<SpreadsheetGradientStop>,
pub gradient_kind: Option<String>,
pub angle: Option<f64>,
pub scaled: Option<bool>,
pub path_type: Option<String>,
pub fill_rectangle: Option<SpreadsheetFillRectangle>,
pub image_reference: Option<String>,
}
impl SpreadsheetFill {
fn merge(&self, patch: &Self) -> Self {
Self {
solid_fill_color: patch
.solid_fill_color
.clone()
.or_else(|| self.solid_fill_color.clone()),
pattern_type: patch
.pattern_type
.clone()
.or_else(|| self.pattern_type.clone()),
pattern_foreground_color: patch
.pattern_foreground_color
.clone()
.or_else(|| self.pattern_foreground_color.clone()),
pattern_background_color: patch
.pattern_background_color
.clone()
.or_else(|| self.pattern_background_color.clone()),
color_transforms: if patch.color_transforms.is_empty() {
self.color_transforms.clone()
} else {
patch.color_transforms.clone()
},
gradient_fill_type: patch
.gradient_fill_type
.clone()
.or_else(|| self.gradient_fill_type.clone()),
gradient_stops: if patch.gradient_stops.is_empty() {
self.gradient_stops.clone()
} else {
patch.gradient_stops.clone()
},
gradient_kind: patch
.gradient_kind
.clone()
.or_else(|| self.gradient_kind.clone()),
angle: patch.angle.or(self.angle),
scaled: patch.scaled.or(self.scaled),
path_type: patch.path_type.clone().or_else(|| self.path_type.clone()),
fill_rectangle: patch
.fill_rectangle
.clone()
.or_else(|| self.fill_rectangle.clone()),
image_reference: patch
.image_reference
.clone()
.or_else(|| self.image_reference.clone()),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct SpreadsheetBorderLine {
pub style: Option<String>,
pub color: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct SpreadsheetBorder {
pub top: Option<SpreadsheetBorderLine>,
pub right: Option<SpreadsheetBorderLine>,
pub bottom: Option<SpreadsheetBorderLine>,
pub left: Option<SpreadsheetBorderLine>,
}
impl SpreadsheetBorder {
fn merge(&self, patch: &Self) -> Self {
Self {
top: patch.top.clone().or_else(|| self.top.clone()),
right: patch.right.clone().or_else(|| self.right.clone()),
bottom: patch.bottom.clone().or_else(|| self.bottom.clone()),
left: patch.left.clone().or_else(|| self.left.clone()),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct SpreadsheetAlignment {
pub horizontal: Option<String>,
pub vertical: Option<String>,
}
impl SpreadsheetAlignment {
fn merge(&self, patch: &Self) -> Self {
Self {
horizontal: patch.horizontal.clone().or_else(|| self.horizontal.clone()),
vertical: patch.vertical.clone().or_else(|| self.vertical.clone()),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct SpreadsheetNumberFormat {
pub format_id: Option<u32>,
pub format_code: Option<String>,
}
impl SpreadsheetNumberFormat {
fn merge(&self, patch: &Self) -> Self {
Self {
format_id: patch.format_id.or(self.format_id),
format_code: patch
.format_code
.clone()
.or_else(|| self.format_code.clone()),
}
}
fn normalized(mut self) -> Self {
if self.format_code.is_none() {
self.format_code = self.format_id.and_then(builtin_number_format_code);
}
self
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
pub struct SpreadsheetCellFormat {
pub text_style_id: Option<u32>,
pub fill_id: Option<u32>,
pub border_id: Option<u32>,
pub alignment: Option<SpreadsheetAlignment>,
pub number_format_id: Option<u32>,
pub wrap_text: Option<bool>,
pub base_cell_style_format_id: Option<u32>,
}
impl SpreadsheetCellFormat {
pub fn wrap(mut self) -> Self {
self.wrap_text = Some(true);
self
}
pub fn unwrap(mut self) -> Self {
self.wrap_text = Some(false);
self
}
fn merge(&self, patch: &Self) -> Self {
Self {
text_style_id: patch.text_style_id.or(self.text_style_id),
fill_id: patch.fill_id.or(self.fill_id),
border_id: patch.border_id.or(self.border_id),
alignment: match (&self.alignment, &patch.alignment) {
(Some(base), Some(update)) => Some(base.merge(update)),
(None, Some(update)) => Some(update.clone()),
(Some(base), None) => Some(base.clone()),
(None, None) => None,
},
number_format_id: patch.number_format_id.or(self.number_format_id),
wrap_text: patch.wrap_text.or(self.wrap_text),
base_cell_style_format_id: patch
.base_cell_style_format_id
.or(self.base_cell_style_format_id),
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
pub struct SpreadsheetDifferentialFormat {
pub text_style_id: Option<u32>,
pub fill_id: Option<u32>,
pub border_id: Option<u32>,
pub alignment: Option<SpreadsheetAlignment>,
pub number_format_id: Option<u32>,
pub wrap_text: Option<bool>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SpreadsheetCellFormatSummary {
pub style_index: u32,
pub text_style: Option<SpreadsheetTextStyle>,
pub fill: Option<SpreadsheetFill>,
pub border: Option<SpreadsheetBorder>,
pub alignment: Option<SpreadsheetAlignment>,
pub number_format: Option<SpreadsheetNumberFormat>,
pub wrap_text: Option<bool>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SpreadsheetRangeFormat {
pub sheet_name: String,
pub range: String,
}
impl SpreadsheetRangeFormat {
pub fn new(sheet_name: String, range: &CellRange) -> Self {
Self {
sheet_name,
range: range.to_a1(),
}
}
pub fn range_ref(&self) -> Result<SpreadsheetCellRangeRef, SpreadsheetArtifactError> {
let range = CellRange::parse(&self.range)?;
Ok(SpreadsheetCellRangeRef::new(
self.sheet_name.clone(),
&range,
))
}
pub fn top_left_style_index(
&self,
sheet: &SpreadsheetSheet,
) -> Result<u32, SpreadsheetArtifactError> {
self.range_ref()?.top_left_style_index(sheet)
}
pub fn top_left_cell_format(
&self,
artifact: &SpreadsheetArtifact,
sheet: &SpreadsheetSheet,
) -> Result<Option<SpreadsheetCellFormatSummary>, SpreadsheetArtifactError> {
let range = self.range_ref()?.range()?;
Ok(artifact.cell_format_summary(sheet.top_left_style_index(&range)))
}
}
impl SpreadsheetArtifact {
pub fn create_text_style(
&mut self,
style: SpreadsheetTextStyle,
source_style_id: Option<u32>,
merge_with_existing_components: bool,
) -> Result<u32, SpreadsheetArtifactError> {
let created = if let Some(source_style_id) = source_style_id {
let source = self
.text_styles
.get(&source_style_id)
.cloned()
.ok_or_else(|| SpreadsheetArtifactError::InvalidArgs {
action: "create_text_style".to_string(),
message: format!("text style `{source_style_id}` was not found"),
})?;
if merge_with_existing_components {
source.merge(&style)
} else {
style
}
} else {
style
};
Ok(insert_with_next_id(&mut self.text_styles, created))
}
pub fn get_text_style(&self, style_id: u32) -> Option<&SpreadsheetTextStyle> {
self.text_styles.get(&style_id)
}
pub fn create_fill(
&mut self,
fill: SpreadsheetFill,
source_fill_id: Option<u32>,
merge_with_existing_components: bool,
) -> Result<u32, SpreadsheetArtifactError> {
let created = if let Some(source_fill_id) = source_fill_id {
let source = self.fills.get(&source_fill_id).cloned().ok_or_else(|| {
SpreadsheetArtifactError::InvalidArgs {
action: "create_fill".to_string(),
message: format!("fill `{source_fill_id}` was not found"),
}
})?;
if merge_with_existing_components {
source.merge(&fill)
} else {
fill
}
} else {
fill
};
Ok(insert_with_next_id(&mut self.fills, created))
}
pub fn get_fill(&self, fill_id: u32) -> Option<&SpreadsheetFill> {
self.fills.get(&fill_id)
}
pub fn create_border(
&mut self,
border: SpreadsheetBorder,
source_border_id: Option<u32>,
merge_with_existing_components: bool,
) -> Result<u32, SpreadsheetArtifactError> {
let created = if let Some(source_border_id) = source_border_id {
let source = self
.borders
.get(&source_border_id)
.cloned()
.ok_or_else(|| SpreadsheetArtifactError::InvalidArgs {
action: "create_border".to_string(),
message: format!("border `{source_border_id}` was not found"),
})?;
if merge_with_existing_components {
source.merge(&border)
} else {
border
}
} else {
border
};
Ok(insert_with_next_id(&mut self.borders, created))
}
pub fn get_border(&self, border_id: u32) -> Option<&SpreadsheetBorder> {
self.borders.get(&border_id)
}
pub fn create_number_format(
&mut self,
format: SpreadsheetNumberFormat,
source_number_format_id: Option<u32>,
merge_with_existing_components: bool,
) -> Result<u32, SpreadsheetArtifactError> {
let created = if let Some(source_number_format_id) = source_number_format_id {
let source = self
.number_formats
.get(&source_number_format_id)
.cloned()
.ok_or_else(|| SpreadsheetArtifactError::InvalidArgs {
action: "create_number_format".to_string(),
message: format!("number format `{source_number_format_id}` was not found"),
})?;
if merge_with_existing_components {
source.merge(&format)
} else {
format
}
} else {
format
};
Ok(insert_with_next_id(
&mut self.number_formats,
created.normalized(),
))
}
pub fn get_number_format(&self, number_format_id: u32) -> Option<&SpreadsheetNumberFormat> {
self.number_formats.get(&number_format_id)
}
pub fn create_cell_format(
&mut self,
format: SpreadsheetCellFormat,
source_format_id: Option<u32>,
merge_with_existing_components: bool,
) -> Result<u32, SpreadsheetArtifactError> {
let created = if let Some(source_format_id) = source_format_id {
let source = self
.cell_formats
.get(&source_format_id)
.cloned()
.ok_or_else(|| SpreadsheetArtifactError::InvalidArgs {
action: "create_cell_format".to_string(),
message: format!("cell format `{source_format_id}` was not found"),
})?;
if merge_with_existing_components {
source.merge(&format)
} else {
format
}
} else {
format
};
Ok(insert_with_next_id(&mut self.cell_formats, created))
}
pub fn get_cell_format(&self, format_id: u32) -> Option<&SpreadsheetCellFormat> {
self.cell_formats.get(&format_id)
}
pub fn create_differential_format(&mut self, format: SpreadsheetDifferentialFormat) -> u32 {
insert_with_next_id(&mut self.differential_formats, format)
}
pub fn get_differential_format(
&self,
format_id: u32,
) -> Option<&SpreadsheetDifferentialFormat> {
self.differential_formats.get(&format_id)
}
pub fn resolve_cell_format(&self, style_index: u32) -> Option<SpreadsheetCellFormat> {
let format = self.cell_formats.get(&style_index)?.clone();
resolve_cell_format_recursive(&self.cell_formats, &format, 0)
}
pub fn cell_format_summary(&self, style_index: u32) -> Option<SpreadsheetCellFormatSummary> {
let resolved = self.resolve_cell_format(style_index)?;
Some(SpreadsheetCellFormatSummary {
style_index,
text_style: resolved
.text_style_id
.and_then(|id| self.text_styles.get(&id).cloned()),
fill: resolved.fill_id.and_then(|id| self.fills.get(&id).cloned()),
border: resolved
.border_id
.and_then(|id| self.borders.get(&id).cloned()),
alignment: resolved.alignment,
number_format: resolved
.number_format_id
.and_then(|id| self.number_formats.get(&id).cloned()),
wrap_text: resolved.wrap_text,
})
}
}
impl SpreadsheetSheet {
pub fn range_format(&self, range: &CellRange) -> SpreadsheetRangeFormat {
SpreadsheetRangeFormat::new(self.name.clone(), range)
}
}
fn insert_with_next_id<T>(map: &mut BTreeMap<u32, T>, value: T) -> u32 {
let next_id = map.last_key_value().map(|(key, _)| key + 1).unwrap_or(1);
map.insert(next_id, value);
next_id
}
fn resolve_cell_format_recursive(
cell_formats: &BTreeMap<u32, SpreadsheetCellFormat>,
format: &SpreadsheetCellFormat,
depth: usize,
) -> Option<SpreadsheetCellFormat> {
if depth > 32 {
return None;
}
let base = format
.base_cell_style_format_id
.and_then(|id| cell_formats.get(&id))
.and_then(|base| resolve_cell_format_recursive(cell_formats, base, depth + 1));
Some(match base {
Some(base) => base.merge(format),
None => format.clone(),
})
}
fn builtin_number_format_code(format_id: u32) -> Option<String> {
match format_id {
0 => Some("General".to_string()),
1 => Some("0".to_string()),
2 => Some("0.00".to_string()),
3 => Some("#,##0".to_string()),
4 => Some("#,##0.00".to_string()),
9 => Some("0%".to_string()),
10 => Some("0.00%".to_string()),
_ => None,
}
}

View File

@@ -1,630 +0,0 @@
use std::collections::BTreeMap;
use std::collections::BTreeSet;
use serde::Deserialize;
use serde::Serialize;
use crate::CellAddress;
use crate::CellRange;
use crate::SpreadsheetArtifactError;
use crate::SpreadsheetCellValue;
use crate::SpreadsheetSheet;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SpreadsheetTableColumn {
pub id: u32,
pub name: String,
pub totals_row_label: Option<String>,
pub totals_row_function: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SpreadsheetTable {
pub id: u32,
pub name: String,
pub display_name: String,
pub range: String,
pub header_row_count: u32,
pub totals_row_count: u32,
pub style_name: Option<String>,
pub show_first_column: bool,
pub show_last_column: bool,
pub show_row_stripes: bool,
pub show_column_stripes: bool,
#[serde(default)]
pub columns: Vec<SpreadsheetTableColumn>,
#[serde(default)]
pub filters: BTreeMap<u32, String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SpreadsheetTableView {
pub id: u32,
pub name: String,
pub display_name: String,
pub address: String,
pub full_range: String,
pub header_row_count: u32,
pub totals_row_count: u32,
pub totals_row_visible: bool,
pub header_row_range: Option<String>,
pub data_body_range: Option<String>,
pub totals_row_range: Option<String>,
pub style_name: Option<String>,
pub show_first_column: bool,
pub show_last_column: bool,
pub show_row_stripes: bool,
pub show_column_stripes: bool,
pub columns: Vec<SpreadsheetTableColumn>,
}
#[derive(Debug, Clone, Default)]
pub struct SpreadsheetTableLookup<'a> {
pub name: Option<&'a str>,
pub display_name: Option<&'a str>,
pub id: Option<u32>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct SpreadsheetCreateTableOptions {
pub name: Option<String>,
pub display_name: Option<String>,
pub header_row_count: u32,
pub totals_row_count: u32,
pub style_name: Option<String>,
pub show_first_column: bool,
pub show_last_column: bool,
pub show_row_stripes: bool,
pub show_column_stripes: bool,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct SpreadsheetTableStyleOptions {
pub style_name: Option<String>,
pub show_first_column: Option<bool>,
pub show_last_column: Option<bool>,
pub show_row_stripes: Option<bool>,
pub show_column_stripes: Option<bool>,
}
impl SpreadsheetTable {
pub fn range(&self) -> Result<CellRange, SpreadsheetArtifactError> {
CellRange::parse(&self.range)
}
pub fn address(&self) -> String {
self.range.clone()
}
pub fn full_range(&self) -> String {
self.range.clone()
}
pub fn totals_row_visible(&self) -> bool {
self.totals_row_count > 0
}
pub fn header_row_range(&self) -> Result<Option<CellRange>, SpreadsheetArtifactError> {
if self.header_row_count == 0 {
return Ok(None);
}
let range = self.range()?;
Ok(Some(CellRange::from_start_end(
range.start,
CellAddress {
column: range.end.column,
row: range.start.row + self.header_row_count - 1,
},
)))
}
pub fn data_body_range(&self) -> Result<Option<CellRange>, SpreadsheetArtifactError> {
let range = self.range()?;
let start_row = range.start.row + self.header_row_count;
let end_row = range.end.row.saturating_sub(self.totals_row_count);
if start_row > end_row {
return Ok(None);
}
Ok(Some(CellRange::from_start_end(
CellAddress {
column: range.start.column,
row: start_row,
},
CellAddress {
column: range.end.column,
row: end_row,
},
)))
}
pub fn totals_row_range(&self) -> Result<Option<CellRange>, SpreadsheetArtifactError> {
if self.totals_row_count == 0 {
return Ok(None);
}
let range = self.range()?;
Ok(Some(CellRange::from_start_end(
CellAddress {
column: range.start.column,
row: range.end.row - self.totals_row_count + 1,
},
range.end,
)))
}
pub fn view(&self) -> Result<SpreadsheetTableView, SpreadsheetArtifactError> {
Ok(SpreadsheetTableView {
id: self.id,
name: self.name.clone(),
display_name: self.display_name.clone(),
address: self.address(),
full_range: self.full_range(),
header_row_count: self.header_row_count,
totals_row_count: self.totals_row_count,
totals_row_visible: self.totals_row_visible(),
header_row_range: self.header_row_range()?.map(|range| range.to_a1()),
data_body_range: self.data_body_range()?.map(|range| range.to_a1()),
totals_row_range: self.totals_row_range()?.map(|range| range.to_a1()),
style_name: self.style_name.clone(),
show_first_column: self.show_first_column,
show_last_column: self.show_last_column,
show_row_stripes: self.show_row_stripes,
show_column_stripes: self.show_column_stripes,
columns: self.columns.clone(),
})
}
}
impl SpreadsheetSheet {
pub fn create_table(
&mut self,
action: &str,
range: &CellRange,
options: SpreadsheetCreateTableOptions,
) -> Result<u32, SpreadsheetArtifactError> {
validate_table_geometry(
action,
range,
options.header_row_count,
options.totals_row_count,
)?;
for table in &self.tables {
let table_range = table.range()?;
if table_range.intersects(range) {
return Err(SpreadsheetArtifactError::InvalidArgs {
action: action.to_string(),
message: format!(
"table range `{}` intersects existing table `{}`",
range.to_a1(),
table.name
),
});
}
}
let next_id = self.tables.iter().map(|table| table.id).max().unwrap_or(0) + 1;
let name = options.name.unwrap_or_else(|| format!("Table{next_id}"));
if name.trim().is_empty() {
return Err(SpreadsheetArtifactError::InvalidArgs {
action: action.to_string(),
message: "table name cannot be empty".to_string(),
});
}
let display_name = options.display_name.unwrap_or_else(|| name.clone());
if display_name.trim().is_empty() {
return Err(SpreadsheetArtifactError::InvalidArgs {
action: action.to_string(),
message: "table display_name cannot be empty".to_string(),
});
}
ensure_unique_table_name(&self.tables, action, &name, &display_name, None)?;
let columns = build_table_columns(self, range, options.header_row_count);
self.tables.push(SpreadsheetTable {
id: next_id,
name,
display_name,
range: range.to_a1(),
header_row_count: options.header_row_count,
totals_row_count: options.totals_row_count,
style_name: options.style_name,
show_first_column: options.show_first_column,
show_last_column: options.show_last_column,
show_row_stripes: options.show_row_stripes,
show_column_stripes: options.show_column_stripes,
columns,
filters: BTreeMap::new(),
});
Ok(next_id)
}
pub fn list_tables(
&self,
range: Option<&CellRange>,
) -> Result<Vec<SpreadsheetTableView>, SpreadsheetArtifactError> {
self.tables
.iter()
.filter(|table| {
range.is_none_or(|target| {
table
.range()
.map(|table_range| table_range.intersects(target))
.unwrap_or(false)
})
})
.map(SpreadsheetTable::view)
.collect()
}
pub fn get_table(
&self,
action: &str,
lookup: SpreadsheetTableLookup<'_>,
) -> Result<&SpreadsheetTable, SpreadsheetArtifactError> {
self.table_lookup_internal(action, lookup)
}
pub fn get_table_view(
&self,
action: &str,
lookup: SpreadsheetTableLookup<'_>,
) -> Result<SpreadsheetTableView, SpreadsheetArtifactError> {
self.get_table(action, lookup)?.view()
}
pub fn delete_table(
&mut self,
action: &str,
lookup: SpreadsheetTableLookup<'_>,
) -> Result<(), SpreadsheetArtifactError> {
let index = self.table_index(action, lookup)?;
self.tables.remove(index);
Ok(())
}
pub fn set_table_style(
&mut self,
action: &str,
lookup: SpreadsheetTableLookup<'_>,
options: SpreadsheetTableStyleOptions,
) -> Result<(), SpreadsheetArtifactError> {
let table = self.table_lookup_mut(action, lookup)?;
table.style_name = options.style_name;
if let Some(value) = options.show_first_column {
table.show_first_column = value;
}
if let Some(value) = options.show_last_column {
table.show_last_column = value;
}
if let Some(value) = options.show_row_stripes {
table.show_row_stripes = value;
}
if let Some(value) = options.show_column_stripes {
table.show_column_stripes = value;
}
Ok(())
}
pub fn clear_table_filters(
&mut self,
action: &str,
lookup: SpreadsheetTableLookup<'_>,
) -> Result<(), SpreadsheetArtifactError> {
self.table_lookup_mut(action, lookup)?.filters.clear();
Ok(())
}
pub fn reapply_table_filters(
&mut self,
action: &str,
lookup: SpreadsheetTableLookup<'_>,
) -> Result<(), SpreadsheetArtifactError> {
let _ = self.table_lookup_mut(action, lookup)?;
Ok(())
}
pub fn rename_table_column(
&mut self,
action: &str,
lookup: SpreadsheetTableLookup<'_>,
column_id: Option<u32>,
column_name: Option<&str>,
new_name: String,
) -> Result<SpreadsheetTableColumn, SpreadsheetArtifactError> {
if new_name.trim().is_empty() {
return Err(SpreadsheetArtifactError::InvalidArgs {
action: action.to_string(),
message: "table column name cannot be empty".to_string(),
});
}
let table = self.table_lookup_mut(action, lookup)?;
if table
.columns
.iter()
.any(|column| column.name == new_name && Some(column.id) != column_id)
{
return Err(SpreadsheetArtifactError::InvalidArgs {
action: action.to_string(),
message: format!("table column `{new_name}` already exists"),
});
}
let column = table_column_lookup_mut(&mut table.columns, action, column_id, column_name)?;
column.name = new_name;
Ok(column.clone())
}
pub fn set_table_column_totals(
&mut self,
action: &str,
lookup: SpreadsheetTableLookup<'_>,
column_id: Option<u32>,
column_name: Option<&str>,
totals_row_label: Option<String>,
totals_row_function: Option<String>,
) -> Result<SpreadsheetTableColumn, SpreadsheetArtifactError> {
let table = self.table_lookup_mut(action, lookup)?;
let column = table_column_lookup_mut(&mut table.columns, action, column_id, column_name)?;
column.totals_row_label = totals_row_label;
column.totals_row_function = totals_row_function;
Ok(column.clone())
}
pub fn validate_tables(&self, action: &str) -> Result<(), SpreadsheetArtifactError> {
let mut seen_names = BTreeSet::new();
let mut seen_display_names = BTreeSet::new();
for table in &self.tables {
let range = table.range()?;
validate_table_geometry(
action,
&range,
table.header_row_count,
table.totals_row_count,
)?;
if !seen_names.insert(table.name.clone()) {
return Err(SpreadsheetArtifactError::InvalidArgs {
action: action.to_string(),
message: format!("duplicate table name `{}`", table.name),
});
}
if !seen_display_names.insert(table.display_name.clone()) {
return Err(SpreadsheetArtifactError::InvalidArgs {
action: action.to_string(),
message: format!("duplicate table display_name `{}`", table.display_name),
});
}
let column_names = table
.columns
.iter()
.map(|column| column.name.clone())
.collect::<BTreeSet<_>>();
if column_names.len() != table.columns.len() {
return Err(SpreadsheetArtifactError::InvalidArgs {
action: action.to_string(),
message: format!("table `{}` has duplicate column names", table.name),
});
}
}
for index in 0..self.tables.len() {
for other in index + 1..self.tables.len() {
if self.tables[index]
.range()?
.intersects(&self.tables[other].range()?)
{
return Err(SpreadsheetArtifactError::InvalidArgs {
action: action.to_string(),
message: format!(
"table `{}` intersects table `{}`",
self.tables[index].name, self.tables[other].name
),
});
}
}
}
Ok(())
}
fn table_index(
&self,
action: &str,
lookup: SpreadsheetTableLookup<'_>,
) -> Result<usize, SpreadsheetArtifactError> {
self.tables
.iter()
.position(|table| table_matches_lookup(table, lookup.clone()))
.ok_or_else(|| SpreadsheetArtifactError::InvalidArgs {
action: action.to_string(),
message: describe_missing_table(lookup),
})
}
fn table_lookup_internal(
&self,
action: &str,
lookup: SpreadsheetTableLookup<'_>,
) -> Result<&SpreadsheetTable, SpreadsheetArtifactError> {
self.tables
.iter()
.find(|table| table_matches_lookup(table, lookup.clone()))
.ok_or_else(|| SpreadsheetArtifactError::InvalidArgs {
action: action.to_string(),
message: describe_missing_table(lookup),
})
}
fn table_lookup_mut(
&mut self,
action: &str,
lookup: SpreadsheetTableLookup<'_>,
) -> Result<&mut SpreadsheetTable, SpreadsheetArtifactError> {
let index = self.table_index(action, lookup)?;
Ok(&mut self.tables[index])
}
}
fn table_matches_lookup(table: &SpreadsheetTable, lookup: SpreadsheetTableLookup<'_>) -> bool {
if let Some(name) = lookup.name {
table.name == name
} else if let Some(display_name) = lookup.display_name {
table.display_name == display_name
} else if let Some(id) = lookup.id {
table.id == id
} else {
false
}
}
fn describe_missing_table(lookup: SpreadsheetTableLookup<'_>) -> String {
if let Some(name) = lookup.name {
format!("table name `{name}` was not found")
} else if let Some(display_name) = lookup.display_name {
format!("table display_name `{display_name}` was not found")
} else if let Some(id) = lookup.id {
format!("table id `{id}` was not found")
} else {
"table name, display_name, or id is required".to_string()
}
}
fn ensure_unique_table_name(
tables: &[SpreadsheetTable],
action: &str,
name: &str,
display_name: &str,
exclude_id: Option<u32>,
) -> Result<(), SpreadsheetArtifactError> {
if tables.iter().any(|table| {
Some(table.id) != exclude_id && (table.name == name || table.display_name == name)
}) {
return Err(SpreadsheetArtifactError::InvalidArgs {
action: action.to_string(),
message: format!("table name `{name}` already exists"),
});
}
if tables.iter().any(|table| {
Some(table.id) != exclude_id
&& (table.display_name == display_name || table.name == display_name)
}) {
return Err(SpreadsheetArtifactError::InvalidArgs {
action: action.to_string(),
message: format!("table display_name `{display_name}` already exists"),
});
}
Ok(())
}
fn validate_table_geometry(
action: &str,
range: &CellRange,
header_row_count: u32,
totals_row_count: u32,
) -> Result<(), SpreadsheetArtifactError> {
if range.width() == 0 {
return Err(SpreadsheetArtifactError::InvalidArgs {
action: action.to_string(),
message: "table range must include at least one column".to_string(),
});
}
if header_row_count + totals_row_count > range.height() as u32 {
return Err(SpreadsheetArtifactError::InvalidArgs {
action: action.to_string(),
message: "table range is smaller than header and totals rows".to_string(),
});
}
Ok(())
}
fn build_table_columns(
sheet: &SpreadsheetSheet,
range: &CellRange,
header_row_count: u32,
) -> Vec<SpreadsheetTableColumn> {
let header_row = range.start.row + header_row_count.saturating_sub(1);
let default_names = (0..range.width())
.map(|index| format!("Column{}", index + 1))
.collect::<Vec<_>>();
let names = unique_table_column_names(
(range.start.column..=range.end.column)
.enumerate()
.map(|(index, column)| {
if header_row_count == 0 {
return default_names[index].clone();
}
sheet
.get_cell(CellAddress {
column,
row: header_row,
})
.and_then(|cell| cell.value.as_ref())
.map(cell_value_to_table_header)
.filter(|value| !value.trim().is_empty())
.unwrap_or_else(|| default_names[index].clone())
})
.collect::<Vec<_>>(),
);
names
.into_iter()
.enumerate()
.map(|(index, name)| SpreadsheetTableColumn {
id: index as u32 + 1,
name,
totals_row_label: None,
totals_row_function: None,
})
.collect()
}
fn unique_table_column_names(names: Vec<String>) -> Vec<String> {
let mut seen = BTreeMap::<String, u32>::new();
names
.into_iter()
.map(|name| {
let entry = seen.entry(name.clone()).or_insert(0);
*entry += 1;
if *entry == 1 {
name
} else {
format!("{name}_{}", *entry)
}
})
.collect()
}
fn cell_value_to_table_header(value: &SpreadsheetCellValue) -> String {
match value {
SpreadsheetCellValue::Bool(value) => value.to_string(),
SpreadsheetCellValue::Integer(value) => value.to_string(),
SpreadsheetCellValue::Float(value) => value.to_string(),
SpreadsheetCellValue::String(value)
| SpreadsheetCellValue::DateTime(value)
| SpreadsheetCellValue::Error(value) => value.clone(),
}
}
fn table_column_lookup_mut<'a>(
columns: &'a mut [SpreadsheetTableColumn],
action: &str,
column_id: Option<u32>,
column_name: Option<&str>,
) -> Result<&'a mut SpreadsheetTableColumn, SpreadsheetArtifactError> {
columns
.iter_mut()
.find(|column| {
if let Some(column_id) = column_id {
column.id == column_id
} else if let Some(column_name) = column_name {
column.name == column_name
} else {
false
}
})
.ok_or_else(|| SpreadsheetArtifactError::InvalidArgs {
action: action.to_string(),
message: if let Some(column_id) = column_id {
format!("table column id `{column_id}` was not found")
} else if let Some(column_name) = column_name {
format!("table column `{column_name}` was not found")
} else {
"table column id or name is required".to_string()
},
})
}

Some files were not shown because too many files have changed in this diff Show More