Skip to main content

cratefield_cli/
client_ts.rs

1//! `fz client-ts` (issue #155): a `/__surface` contract document in, the
2//! files of a typed `@cratefield/client` package out, written to `--out`.
3//!
4//! The other harness-free commands work on what a venture *has* — a
5//! manifest (`fz build`), a migration directory, its environment
6//! (`fz push`). This one works on what a venture *serves*: `--surface`
7//! names the JSON the running venture publishes at `/__surface`, or `-`
8//! to read the body off stdin, so a client can be generated without
9//! checking the venture out at all:
10//!
11//! ```text
12//! curl -s https://venture.example/__surface | fz client-ts --surface - --out ./client
13//! ```
14//!
15//! Generation itself is [`cratefield_client_ts::generate`] — pure,
16//! deterministic, no I/O — so this module is only the I/O around it: read
17//! the document, write the files, and say which of the two failed in the
18//! doctor's coded shape (`--json` prints exactly one object on stdout,
19//! every refusal carrying a stable code from [`crate::codes`]).
20
21use crate::codes::{CODES, DoctorCodeDef};
22use cratefield_client_ts::{GenerateError, GeneratedPackage};
23use std::io::Read;
24use std::path::Path;
25use std::process::ExitCode;
26
27/// The `schema` field of the `fz client-ts --json` object: consumers
28/// branch on it, so it only ever grows. The same wire contract
29/// `fz doctor --json` carries ([`crate::doctor::REPORT_SCHEMA`]).
30pub const SCHEMA: u32 = 1;
31
32/// A refused run: its stable code from the catalogue ([`crate::codes`])
33/// plus the human message, which is exactly what the prose path prints
34/// after `fz: `.
35#[derive(Debug)]
36pub struct Refusal {
37    /// The catalogue definition this refusal is classified under.
38    pub code: &'static DoctorCodeDef,
39    /// The refusal message, exactly as the prose path prints it.
40    pub message: String,
41}
42
43impl Refusal {
44    /// The exact stdout payload of a refused `fz client-ts --json`: one
45    /// object, one line, the doctor's failure shape.
46    ///
47    /// # Errors
48    ///
49    /// Only if `serde_json` cannot serialize the payload — a struct of
50    /// strings and numbers cannot hit that, but the Result keeps every
51    /// caller total instead of panicking.
52    pub fn render_json(&self) -> Result<String, serde_json::Error> {
53        serde_json::to_string(&BarePayload {
54            schema: SCHEMA,
55            ok: false,
56            failures: vec![FailureRef {
57                code: self.code.code,
58                message: &self.message,
59            }],
60        })
61    }
62}
63
64/// What a successful run did: the generated package's identity and the
65/// files it wrote, in write order.
66#[derive(Debug)]
67pub struct Generated {
68    /// The venture the client speaks for, from the document.
69    pub venture: String,
70    /// The npm package name the files implement: `@cratefield/client`.
71    pub package_name: String,
72    /// The sha256 composition hash over the generated files, for a
73    /// reproducible-publish check.
74    pub composition_hash: String,
75    /// The directory the files went to, as `--out` spelled it.
76    pub out: String,
77    /// The package-relative paths written, in write order.
78    pub files: Vec<String>,
79}
80
81impl Generated {
82    /// The exact stdout payload of a successful `fz client-ts --json`:
83    /// one object, one line.
84    ///
85    /// # Errors
86    ///
87    /// Only if `serde_json` cannot serialize the payload — a struct of
88    /// strings and numbers cannot hit that, but the Result keeps every
89    /// caller total instead of panicking.
90    pub fn render_json(&self) -> Result<String, serde_json::Error> {
91        serde_json::to_string(&WrittenPayload {
92            schema: SCHEMA,
93            ok: true,
94            failures: Vec::new(),
95            package: &self.package_name,
96            venture: &self.venture,
97            composition_hash: &self.composition_hash,
98            out: self.out.clone(),
99            files_written: self.files.len(),
100            files: &self.files,
101        })
102    }
103}
104
105/// One failure as it travels on the wire: the catalogue slug plus the
106/// message, in the doctor's shape.
107#[derive(serde::Serialize)]
108struct FailureRef<'a> {
109    code: &'a str,
110    message: &'a str,
111}
112
113/// The refused `fz client-ts --json` object: the doctor's bare payload.
114#[derive(serde::Serialize)]
115struct BarePayload<'a> {
116    schema: u32,
117    ok: bool,
118    failures: Vec<FailureRef<'a>>,
119}
120
121/// The successful `fz client-ts --json` object.
122#[derive(serde::Serialize)]
123struct WrittenPayload<'a> {
124    schema: u32,
125    ok: bool,
126    failures: Vec<FailureRef<'a>>,
127    package: &'a str,
128    venture: &'a str,
129    composition_hash: &'a str,
130    out: String,
131    files_written: usize,
132    files: &'a [String],
133}
134
135/// Runs `fz client-ts` in the prose discipline: the summary on stdout,
136/// a refusal as the message `finish` prints after `fz: `. `source` is
137/// the `--surface` value — a path, or `-` for stdin.
138///
139/// # Errors
140///
141/// A human-readable message naming what refused and what to do about it.
142pub fn run(source: &str, out: &Path) -> Result<(), String> {
143    match client_ts(source, out) {
144        Ok(generated) => {
145            print_summary(&generated);
146            Ok(())
147        }
148        Err(refusal) => Err(refusal.message),
149    }
150}
151
152/// Runs `fz client-ts --json`: exactly one JSON object on stdout, nothing
153/// else on either stream, and the verdict as the exit code.
154#[must_use = "call process::exit with the returned ExitCode"]
155pub fn run_json(source: &str, out: &Path) -> ExitCode {
156    let (payload, exit) = match client_ts(source, out) {
157        Ok(generated) => (generated.render_json(), ExitCode::SUCCESS),
158        Err(refusal) => (refusal.render_json(), ExitCode::FAILURE),
159    };
160    match payload {
161        Ok(line) => {
162            println!("{line}");
163            exit
164        }
165        Err(err) => {
166            eprintln!("fz: cannot serialize the report: {err}");
167            ExitCode::FAILURE
168        }
169    }
170}
171
172/// The whole command minus the argv plumbing: read the document, generate
173/// the package, write it under `out`. Public so the acceptance tests can
174/// pin the messages and the JSON payloads the two disciplines print
175/// without driving argv.
176///
177/// # Errors
178///
179/// [`Refusal`] with the stable code for the step that refused: reading
180/// the document, reading it as a `/__surface` contract, generating from
181/// it, or writing the files out.
182pub fn client_ts(source: &str, out: &Path) -> Result<Generated, Refusal> {
183    let document = read_document(source)?;
184    from_document(&document, &describe(source), out)
185}
186
187/// Generates and writes from a document **body** — the seam
188/// [`client_ts`] reaches once it has read `--surface`. `described` is how
189/// a refusal should name the document ("the document on stdin", or the
190/// path it was read from).
191///
192/// # Errors
193///
194/// [`Refusal`], as [`client_ts`].
195pub fn from_document(document: &str, described: &str, out: &Path) -> Result<Generated, Refusal> {
196    // Two reads, on purpose: "not valid JSON" and "JSON, but not a
197    // surface document" are different mistakes with different fixes, and
198    // each gets its own stable code.
199    let value: serde_json::Value = serde_json::from_str(document).map_err(|err| Refusal {
200        code: &CODES.surface_unreadable,
201        message: format!("{described} is not valid JSON: {err}"),
202    })?;
203    let parsed: cratefield_core::SurfaceDocument =
204        serde_json::from_value(value).map_err(|err| Refusal {
205            code: &CODES.surface_invalid,
206            message: format!(
207                "{described} is not a /__surface document: {err} — point --surface at the \
208                 JSON the venture serves at /__surface"
209            ),
210        })?;
211    let package = cratefield_client_ts::generate(&parsed).map_err(|error| classify(&error))?;
212    write_package(&package, out)
213}
214
215/// Reads the `--surface` value: `-` is the document on stdin, anything
216/// else a path.
217fn read_document(source: &str) -> Result<String, Refusal> {
218    if source == "-" {
219        let mut buffer = String::new();
220        std::io::stdin()
221            .read_to_string(&mut buffer)
222            .map_err(|err| Refusal {
223                code: &CODES.surface_unreadable,
224                message: format!("cannot read the surface document from stdin: {err}"),
225            })?;
226        return Ok(buffer);
227    }
228    std::fs::read_to_string(source).map_err(|err| Refusal {
229        code: &CODES.surface_unreadable,
230        message: format!("cannot read {}: {err}", Path::new(source).display()),
231    })
232}
233
234/// How a refusal should name the document, from the `--surface` value.
235fn describe(source: &str) -> String {
236    if source == "-" {
237        "the document on stdin".to_owned()
238    } else {
239        format!("the document at {source}")
240    }
241}
242
243/// The generator's refusals are contract refusals, and each variant is a
244/// different thing an agent would branch on, so each gets its own stable
245/// code; the message is the generator's own, which already names the fix.
246fn classify(error: &GenerateError) -> Refusal {
247    let code = match error {
248        GenerateError::UnsupportedSurfaceApi { .. } => &CODES.surface_unsupported_contract,
249        GenerateError::NoTablesModule { .. } => &CODES.surface_no_tables_module,
250        GenerateError::UnknownAction { .. } | GenerateError::UnreadableAction { .. } => {
251            &CODES.surface_action_unreadable
252        }
253        GenerateError::UnsupportedTableName { .. } => &CODES.surface_table_name_invalid,
254    };
255    Refusal {
256        code,
257        message: error.to_string(),
258    }
259}
260
261/// Whether a generated file's package-relative path is safe to join onto
262/// `--out`: relative, with no `..` component anywhere in it. Unreachable
263/// for today's emitter — its six paths are hard-coded constants — and
264/// cheap enough to keep as the guard for the day the file set grows
265/// beyond them.
266fn is_safe_relative(path: &str) -> bool {
267    let path = Path::new(path);
268    !path.is_absolute()
269        && path.components().all(|component| {
270            !matches!(
271                component,
272                std::path::Component::ParentDir
273                    | std::path::Component::RootDir
274                    | std::path::Component::Prefix(_)
275            )
276        })
277}
278
279/// Writes the package to `out`, creating subdirectories, and reports what
280/// landed where. Mirrors `fz build`'s file writing: the generated paths
281/// are package-relative (`src/index.ts`).
282fn write_package(package: &GeneratedPackage, out: &Path) -> Result<Generated, Refusal> {
283    let mut written = Vec::with_capacity(package.files.len());
284    for file in &package.files {
285        if !is_safe_relative(&file.path) {
286            return Err(Refusal {
287                code: &CODES.client_write_failed,
288                message: format!(
289                    "the generated path `{}` is not package-relative, and the generator \
290                     writes only below --out",
291                    file.path
292                ),
293            });
294        }
295        let dest = out.join(&file.path);
296        if let Some(parent) = dest.parent() {
297            std::fs::create_dir_all(parent).map_err(|err| Refusal {
298                code: &CODES.client_write_failed,
299                message: format!("cannot create {}: {err}", parent.display()),
300            })?;
301        }
302        std::fs::write(&dest, &file.contents).map_err(|err| Refusal {
303            code: &CODES.client_write_failed,
304            message: format!("cannot write {}: {err}", dest.display()),
305        })?;
306        written.push(file.path.clone());
307    }
308    Ok(Generated {
309        venture: package.venture.clone(),
310        package_name: package.package_name.clone(),
311        composition_hash: package.composition_hash.clone(),
312        out: out.display().to_string(),
313        files: written,
314    })
315}
316
317/// The prose summary. A result, not a diagnostic — stdout, so a script
318/// can read the file list off it.
319fn print_summary(generated: &Generated) {
320    println!(
321        "fz client-ts: {} (venture: {})",
322        generated.package_name, generated.venture
323    );
324    println!("  composition: sha256:{}", generated.composition_hash);
325    println!(
326        "  wrote {} files to {}:",
327        generated.files.len(),
328        generated.out
329    );
330    for file in &generated.files {
331        println!("    {file}");
332    }
333}
334
335#[cfg(test)]
336mod tests {
337    use super::is_safe_relative;
338
339    /// The write guard: a generated path may travel below `--out` only.
340    /// Unreachable through `emit()` today, so it is pinned here directly.
341    #[test]
342    fn a_generated_path_may_not_escape_out() {
343        assert!(is_safe_relative("src/index.ts"));
344        assert!(is_safe_relative("package.json"));
345        assert!(!is_safe_relative(".."));
346        assert!(!is_safe_relative("../evil.ts"), "a `..` escapes --out");
347        assert!(
348            !is_safe_relative("src/../../evil.ts"),
349            "a buried `..` escapes --out too"
350        );
351        assert!(!is_safe_relative("/etc/cratefield"), "an absolute path");
352    }
353}