diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index efb0167..879773a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,6 +13,8 @@ jobs: - run: rustup toolchain install ${{ matrix.rust }} --profile minimal --component rustfmt,clippy - run: cargo +${{ matrix.rust }} test --locked - run: cargo +${{ matrix.rust }} run --locked --example retry_command + - run: cargo +${{ matrix.rust }} run --locked --example context_walkthrough + - run: python3 tools/context_evidence.py --check - run: cargo +${{ matrix.rust }} fmt --check - run: cargo +${{ matrix.rust }} clippy --locked --all-targets -- -D warnings - run: cargo +${{ matrix.rust }} publish --dry-run --locked --registry crates-io diff --git a/CHANGELOG.md b/CHANGELOG.md index 17e891f..15674c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## Unreleased + +- Add a runnable context walkthrough: a host edit followed by Undo restores the + value but invalidates an old draft; Reset does not rebase its captured context. +- Generate a front-page diagram from the example's output and source hashes; + verify both on Rust 1.88 and stable in CI. Include the renderer in source packages. +- Put concrete behavior, local-helper trade-offs and host responsibilities first in + the README. Public API, runtime behavior and published versions are unchanged. + ## 0.1.0-alpha.4 Published to [crates.io](https://crates.io/crates/edit-session/0.1.0-alpha.4) on diff --git a/Cargo.toml b/Cargo.toml index 1af6e77..0a59c26 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,7 +10,7 @@ readme = "PACKAGE.md" publish = ["crates-io"] keywords = ["editing", "draft", "validation", "undo", "no-std"] categories = ["no-std", "gui"] -include = ["src/**", "tests/**", "examples/**", "docs/**", "README.md", "PACKAGE.md", "CHANGELOG.md", "LICENSE-*", "Cargo.toml", "Cargo.lock"] +include = ["src/**", "tests/**", "examples/**", "docs/**", "tools/context_evidence.py", "README.md", "PACKAGE.md", "CHANGELOG.md", "LICENSE-*", "Cargo.toml", "Cargo.lock"] [[example]] name = "retry_command" diff --git a/PACKAGE.md b/PACKAGE.md index 39f85ca..ee2d572 100644 --- a/PACKAGE.md +++ b/PACKAGE.md @@ -45,6 +45,7 @@ must retain the draft for retry, use the borrowed-preview/check pattern in the Check the live value and context again on every attempt. - [API source and contracts](https://github.com/PerchAnton/edit-session/blob/main/src/lib.rs) +- [Runnable example: Undo restores a value but leaves an old draft stale](https://github.com/PerchAnton/edit-session/blob/main/docs/CONTEXT_WALKTHROUGH.md) - [Changelog](https://github.com/PerchAnton/edit-session/blob/main/CHANGELOG.md) - [Integration evidence and limits](https://github.com/PerchAnton/edit-session/blob/main/docs/ADOPTION.md) - [Report a bug](https://github.com/PerchAnton/edit-session/issues/new?template=bug_report.yml) diff --git a/README.md b/README.md index 816900b..da6e2bc 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,45 @@ # edit-session -Draft editing with document, target and revision checks. A standalone Rust library; -the separate [interaction-check](https://github.com/PerchAnton/interaction-check) tool is -an optional way to test consumer workflows, not a dependency of this crate. +Keep an editable draft separate from live data, then check that its document, target +and revision are still current before the host applies a change. A standalone Rust +library: `no_std`, zero runtime dependencies, Rust 1.88+. [![CI](https://github.com/PerchAnton/edit-session/actions/workflows/ci.yml/badge.svg?branch=main&event=push)](https://github.com/PerchAnton/edit-session/actions/workflows/ci.yml) -**[Download](#current-prerelease)** · **[Try recovery](#retry-after-a-rejected-command)** · **[Use the library](#use-from-another-project)** · **[Evidence](#evidence-and-trade-offs)** · **[License](#license)** +**[First example](#same-value-different-edit)** · **[Try recovery](#retry-after-a-rejected-command)** · **[Install](#use-from-another-project)** · **[Evidence](#evidence-and-trade-offs)** · **[License](#license)** + +## Same value, different edit + +![An executable example: an old draft starts at revision 42. Another edit changes the value from 100 to 120; Undo restores 100 at revision 44. The old draft remains stale, including after Reset.](docs/assets/context-walkthrough.svg) + +From a checkout of this repository, run: + +```sh +cargo run --locked --example context_walkthrough +``` + +The example prints four states and asserts the actual library results. A value-only +comparison would accept the restored value after Undo. The host-supplied revision +still differs, so `check` and `finish` return `ContextChanged`. **Reset changes the +preview; it does not rebase an old draft onto the current document.** + +This is an original synchronous teaching model, verified **27 October 2026**, not a +new application regression or a benchmark. The diagram is generated from its output +and checked in CI. [Walkthrough and host responsibilities](docs/CONTEXT_WALKTHROUGH.md) +· [Example source](examples/context_walkthrough.rs) · [Raw evidence](docs/evidence/context-walkthrough.json). + +| Your situation | Reasonable choice | +| --- | --- | +| A small dialog already has correct draft logic | Keep the local helper unless a concrete gap justifies a dependency | +| Several consumers need the same draft validity contract | Evaluate this library against their native regression tests | +| The host command can fail and the user must retry | Use the [borrowed-preview recovery pattern](docs/RECOVERY.md); retain fields until the command succeeds | +| You need widgets, rendering, Undo history or transaction rollback | Those remain responsibilities of the host application | + +The host supplies stable identities, advances revisions, and keeps the check and +command on the same serialized edit path. A correct local helper can provide the +same protection; this example does not claim less code or faster execution. +The separate [interaction-check](https://github.com/PerchAnton/interaction-check) +development tool is optional. Neither project depends on the other. ## Current prerelease @@ -14,6 +47,9 @@ an optional way to test consumer workflows, not a dependency of this crate. **[API documentation](https://docs.rs/edit-session/0.1.0-alpha.4/edit_session/)** · [Download the crate](https://crates.io/api/v1/crates/edit-session/0.1.0-alpha.4/download) · [SHA-256 and installation evidence](docs/evidence/release-alpha4.json) · [Changelog](CHANGELOG.md) · [Use as a dependency](#use-from-another-project) +
+Published package snapshot and verification + ![edit-session alpha 4, published on crates.io 27 October 2026: 0 runtime dependencies, 25 scoped checks and a 34.2 KiB source archive. Check live state, attempt the host command, retain fields and draft on rejection, and close on success.](docs/assets/release.svg) Release snapshot: **16 lifecycle tests + 8 host-example tests + 1 doctest**, @@ -28,6 +64,8 @@ tracks current main, while release facts stay tied to the tagged commit. The [previous GitHub release](https://github.com/PerchAnton/edit-session/releases/tag/v0.1.0-alpha.3) remains available. +
+ Trying the alpha? [Report a bug](https://github.com/PerchAnton/edit-session/issues/new?template=bug_report.yml) with your revision and a short sequence of calls, or read the [alpha compatibility policy](docs/RELEASING.md#alpha-compatibility). diff --git a/docs/CONTEXT_WALKTHROUGH.md b/docs/CONTEXT_WALKTHROUGH.md new file mode 100644 index 0000000..ebc12e6 --- /dev/null +++ b/docs/CONTEXT_WALKTHROUGH.md @@ -0,0 +1,83 @@ +# Same value, different edit + +An open draft remembers the value and context from when editing began. If another +action changes the document and Undo restores the original value, the old draft is +still based on an earlier revision. Comparing only values misses that history. + +Run the [complete Rust example](../examples/context_walkthrough.rs) from a checkout: + +```sh +cargo run --locked --example context_walkthrough +``` + +It calls the real library, uses no third-party dependencies, and checks its results +with assertions. It is an original synchronous teaching model, not a GUI test or a +newly discovered bug in an application. Rust 1.88+ is sufficient. + +| Action | Live value | Host revision | Draft preview | `check` | +| --- | ---: | ---: | ---: | --- | +| Open draft, propose 75 | 100 | 42 | 75 | `Ok(())` | +| Another host edit | 120 | 43 | 75 | `ContextChanged` | +| Host Undo | 100 | 44 | 75 | `ContextChanged` | +| Reset the old draft | 100 | 44 | 100 | `ContextChanged` | + +The original revision remains 42. `finish` returns `ContextChanged`, including after +Reset. The host must reject or dismiss this stale draft rather than apply it to the +newer state. A deliberate reload/reopen can start a **new** session; Reset cannot. + +![Values and revisions from the executable walkthrough.](assets/context-walkthrough.svg) + +## What the application must supply + +The library does not observe edits or increment revisions. The application supplies +stable document and target identities, and a revision that changes on every relevant +edit, Undo and Redo. A wrapping or reused revision can make a different state appear +current; choose a counter/identity scheme that avoids reuse during a session's lifetime. + +Use owned snapshots or immutable shared values. Cloning a pointer to shared mutable +storage does not isolate the draft. Equality must cover all relevant fields. + +Freshness checks and command execution must share the application's serialized edit +path. A successful check followed by a separately scheduled write is not protected +from intervening changes. The library provides no lock or transaction rollback. + +For a host command that may reject an edit, use the existing +[retry example](RECOVERY.md): check, borrow the preview, try the atomic host command, +and close only after success. Retain raw input on rejection. Calling `finish` first +consumes the session and is not a retry strategy. + +## Why use a library instead of a small struct? + +A correct local helper can implement the same checks. This example demonstrates the +contract, not code savings or an advantage over that helper. The reusable part is one +tested implementation of draft validity, original/preview values and context checks. +It is worth a dependency only when consumers need that shared contract. + +| Existing situation | Reasonable choice | +| --- | --- | +| A simple dialog with working local draft logic | Keep the local helper unless a concrete gap is demonstrated | +| Several consumers need the same draft contract | Evaluate edit-session against the same native regression tests | +| Preview, workspace reload or backup already solves the need | Keep that workflow; a new dependency needs a separate reason | +| A useful regression can be expressed as a native Rust test | Use that test; interaction-check is optional | +| A renderer, Undo engine, asynchronous transaction or conflict merge is missing | Solve that in the host; this library does not provide it | + +These choices reflect [Oculante, SSBH Editor and Tarsier feedback](ADOPTION.md#what-feedback-changes-our-approach). +The [PdfCraft evidence](ADOPTION.md#a-demonstrated-failure-in-a-real-consumer) concerns a +complete proposed consumer patch, including host changes. It does not prove that an +external crate is preferable to a local helper, and neither integration is adopted. + +## Reproduce the diagram + +```sh +cargo build --locked --example context_walkthrough +python tools/context_evidence.py +python tools/context_evidence.py --check +``` + +Python is used only to render/verify the repository graphic; using the crate and +running its Rust examples do not require it. Pass `--binary PATH` if Cargo uses a +custom target directory. The script runs the example's `--json` mode, records its +output and LF-normalized source hashes in [the snapshot](evidence/context-walkthrough.json), +and renders the SVG from those values. CI checks both on Rust 1.88 and stable. +These walkthrough assertions are separate from the existing 16 lifecycle tests, +8 recovery tests and one compile-fail doctest; they are not new upstream regressions. diff --git a/docs/assets/README.md b/docs/assets/README.md index d40ea5e..e0e28e9 100644 --- a/docs/assets/README.md +++ b/docs/assets/README.md @@ -1,5 +1,21 @@ # README overview asset +## Executable context walkthrough + +`context-walkthrough.svg` is generated from the real output of +[the Rust example](../../examples/context_walkthrough.rs), recorded on 27 October +2026 in [JSON](../evidence/context-walkthrough.json). The example asserts that a host +edit, Undo and Reset leave an old draft stale. It is a synchronous teaching model, +not an additional application regression or a performance measurement. + +Build with `cargo build --locked --example context_walkthrough`, then run +`python tools/context_evidence.py` from the repository root. Add `--check` to verify +the saved output, LF-normalized source hashes and SVG without rewriting them. +The existing Rust 1.88/stable CI matrix runs this check. See the +[walkthrough](../CONTEXT_WALKTHROUGH.md) for responsibilities and reproduction. + +## Earlier measured overview + `overview.svg` is an editable, self-contained vector graphic. It uses system fonts, explicit light-background colors, a title and a description, with no scripts, external fonts, images or trackers. The repository README repeats its measurements as accessible diff --git a/docs/assets/context-walkthrough.svg b/docs/assets/context-walkthrough.svg new file mode 100644 index 0000000..58a8a6a --- /dev/null +++ b/docs/assets/context-walkthrough.svg @@ -0,0 +1,38 @@ + +Undo restores a value, not the validity of an old draft +The executable Rust example opens a draft at value 100, revision 42, with preview 75. A host edit changes the value to 120 at revision 43. Undo restores 100 at revision 44. The value matches the original again, but edit-session rejects the stale context. Reset restores preview 100 without rebasing the captured revision. Finish returns ContextChanged. + + + +EDIT-SESSION / RUNNABLE CONTEXT WALKTHROUGH +Undo restores the value. The old draft stays stale. +The host supplies a revision that changes on edits, Undo and Redo. + + +1. Open draft +LIVE VALUE +100 +Revision 42 · preview 75 +Ready + +2. Host edits +LIVE VALUE +120 +Revision 43 · preview 75 +ContextChanged + +3. Host undoes +LIVE VALUE +100 +Revision 44 · preview 75 +ContextChanged + +4. Reset draft +LIVE VALUE +100 +Revision 44 · preview 100 +ContextChanged +Same value: 100. Different revision: 42 → 44. finish() returns ContextChanged. +The host must not apply the stale draft. Reset is not a rebase, a rollback or a freshness override. +Example output · verified 2026-10-27 · no GUI, automatic locking or upstream adoption claim. + diff --git a/docs/evidence/context-walkthrough.json b/docs/evidence/context-walkthrough.json new file mode 100644 index 0000000..b471128 --- /dev/null +++ b/docs/evidence/context-walkthrough.json @@ -0,0 +1,46 @@ +{ + "recorded_date": "2026-10-27", + "scope": "Original synchronous teaching model; actual edit-session API, no GUI or upstream test claim", + "source_hash_format": "SHA-256 of UTF-8 text with LF line endings", + "source_files": { + "src/lib.rs": "878d877fc7c8874230c93aafd8321613b3c02b4dcf94865f2629e21fa088a74c", + "examples/context_walkthrough.rs": "4a8fd1642ba38900d757e010e2ce6287e5272c296207a7381e0e4d24e95c307c" + }, + "result": { + "rows": [ + { + "action": "Open draft", + "live": 100, + "revision": 42, + "preview": 75, + "value_matches_original": true, + "check": "Ready" + }, + { + "action": "Host edits", + "live": 120, + "revision": 43, + "preview": 75, + "value_matches_original": false, + "check": "ContextChanged" + }, + { + "action": "Host undoes", + "live": 100, + "revision": 44, + "preview": 75, + "value_matches_original": true, + "check": "ContextChanged" + }, + { + "action": "Reset draft", + "live": 100, + "revision": 44, + "preview": 100, + "value_matches_original": true, + "check": "ContextChanged" + } + ], + "finish": "ContextChanged" + } +} diff --git a/examples/context_walkthrough.rs b/examples/context_walkthrough.rs new file mode 100644 index 0000000..2d2b6ab --- /dev/null +++ b/examples/context_walkthrough.rs @@ -0,0 +1,123 @@ +//! Why an edit followed by Undo still invalidates an older draft. +//! An original synchronous teaching model, not an application integration test. +use edit_session::{EditSession, FinishError}; + +#[derive(Clone, Copy, PartialEq, Eq)] +struct Context { + document: u64, + target: u64, + revision: u64, +} + +struct Row { + action: &'static str, + live: u8, + revision: u64, + preview: u8, + value_matches_original: bool, + check: &'static str, +} + +fn observe( + action: &'static str, + live: u8, + context: Context, + draft: &EditSession, +) -> Row { + let check = match draft.check(&live, &context) { + Ok(()) => "Ready", + Err(FinishError::ContextChanged) => "ContextChanged", + Err(FinishError::ValueChanged) => "ValueChanged", + Err(FinishError::InvalidProposal) => "InvalidProposal", + }; + Row { + action, + live, + revision: context.revision, + preview: *draft.preview(), + value_matches_original: live == *draft.original(), + check, + } +} + +fn main() { + let args: Vec<_> = std::env::args().skip(1).collect(); + let json = match args.as_slice() { + [] => false, + [flag] if flag == "--json" => true, + _ => { + eprintln!("Usage: context_walkthrough [--json]"); + std::process::exit(2); + } + }; + let mut live = 100_u8; + let mut context = Context { + document: 7, + target: 2, + revision: 42, + }; + let mut draft = EditSession::new(live, context); + assert_eq!(draft.propose::<&str>(Ok(75)), Ok(())); + let mut rows = vec![observe("Open draft", live, context, &draft)]; + assert_eq!(draft.check(&live, &context), Ok(())); + + // The host owns both the external edit and its monotonic revision. + live = 120; + context.revision += 1; + rows.push(observe("Host edits", live, context, &draft)); + assert_eq!( + draft.check(&live, &context), + Err(FinishError::ContextChanged) + ); + + // Undo restores the VALUE, but must advance the host's revision again. + live = 100; + context.revision += 1; + rows.push(observe("Host undoes", live, context, &draft)); + assert_eq!(live, *draft.original()); + assert_eq!( + draft.check(&live, &context), + Err(FinishError::ContextChanged) + ); + + // Reset changes the preview, never the captured document/target/revision. + draft.reset(); + rows.push(observe("Reset draft", live, context, &draft)); + assert_eq!(*draft.preview(), live); + assert_eq!(draft.context().revision, 42); + assert_eq!( + draft.finish(&live, &context), + Err(FinishError::ContextChanged) + ); + + if json { + println!("{{\"rows\":["); + for (i, row) in rows.iter().enumerate() { + println!( + "{{\"action\":\"{}\",\"live\":{},\"revision\":{},\"preview\":{},\"value_matches_original\":{},\"check\":\"{}\"}}{}", + row.action, + row.live, + row.revision, + row.preview, + row.value_matches_original, + row.check, + if i + 1 == rows.len() { "" } else { "," } + ); + } + println!("],\"finish\":\"ContextChanged\"}}"); + } else { + println!("Action | Live | Revision | Preview | Value equals original | check()"); + for row in &rows { + println!( + "{:<13} | {:>4} | {:>8} | {:>7} | {:<21} | {}", + row.action, + row.live, + row.revision, + row.preview, + row.value_matches_original, + row.check + ); + } + println!("finish(): ContextChanged. The host must not apply the old draft."); + } +} diff --git a/tools/context_evidence.py b/tools/context_evidence.py new file mode 100644 index 0000000..e4d02d3 --- /dev/null +++ b/tools/context_evidence.py @@ -0,0 +1,76 @@ +"""Run the Rust walkthrough and keep its README diagram tied to real output.""" +import argparse +from datetime import datetime, timezone +import hashlib +from html import escape +import json +from pathlib import Path +import subprocess + +ROOT = Path(__file__).resolve().parents[1] +REPORT = ROOT / "docs/evidence/context-walkthrough.json" +SVG = ROOT / "docs/assets/context-walkthrough.svg" + + +def source_hashes(): + return {name: hashlib.sha256((ROOT / name).read_bytes().replace(b"\r\n", b"\n")).hexdigest() + for name in ("src/lib.rs", "examples/context_walkthrough.rs")} + + +def render(data): + cards = [] + for index, row in enumerate(data["result"]["rows"]): + x = 32 + index * 248 + ready = row["check"] == "Ready" + color = "#087c79" if ready else "#9a461b" + background = "#eaf6f3" if ready else "#fff1e9" + cards.append(f''' + +{index+1}. {escape(row['action'])} +LIVE VALUE +{row['live']} +Revision {row['revision']} · preview {row['preview']} +{row['check']}''') + return f''' +Undo restores a value, not the validity of an old draft +The executable Rust example opens a draft at value 100, revision 42, with preview 75. A host edit changes the value to 120 at revision 43. Undo restores 100 at revision 44. The value matches the original again, but edit-session rejects the stale context. Reset restores preview 100 without rebasing the captured revision. Finish returns ContextChanged. + + + +EDIT-SESSION / RUNNABLE CONTEXT WALKTHROUGH +Undo restores the value. The old draft stays stale. +The host supplies a revision that changes on edits, Undo and Redo. +{''.join(cards)} +Same value: 100. Different revision: 42 → 44. finish() returns ContextChanged. +The host must not apply the stale draft. Reset is not a rebase, a rollback or a freshness override. +Example output · verified {data['recorded_date']} · no GUI, automatic locking or upstream adoption claim. + +''' + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--binary", type=Path, default=ROOT / "target/debug/examples/context_walkthrough") + parser.add_argument("--check", action="store_true") + args = parser.parse_args() + completed = subprocess.run([str(args.binary.resolve()), "--json"], text=True, + capture_output=True, timeout=15, check=True) + result = json.loads(completed.stdout) + if args.check: + data = json.loads(REPORT.read_text(encoding="utf-8")) + if data["result"] != result or data["source_files"] != source_hashes(): + raise SystemExit("Walkthrough changed; run this script without --check") + if SVG.read_text(encoding="utf-8") != render(data): + raise SystemExit("Diagram changed; run this script without --check") + else: + data = {"recorded_date": datetime.now(timezone.utc).date().isoformat(), + "scope": "Original synchronous teaching model; actual edit-session API, no GUI or upstream test claim", + "source_hash_format": "SHA-256 of UTF-8 text with LF line endings", + "source_files": source_hashes(), "result": result} + REPORT.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8") + SVG.write_text(render(data), encoding="utf-8") + print("Context walkthrough output, source hashes and diagram verified") + + +if __name__ == "__main__": + main()