Skip to main content

geop/
main.rs

1//! `geop`: the kernel on the command line.
2//!
3//! `geop compile part.geop` builds a program — the JSON the web
4//! editor saves (see `Program`) — and writes the part it
5//! makes with the exporter the editor offers for the output's extension
6//! (see `geop_cad_base::outputs`), told the options given as `-O key=value`
7//! — by default in the first format the editor offers, its STL mesh — the
8//! parts it places included, each where it is placed, read from the
9//! program files next to it.
10//!
11//! `geop prebuild` builds programs on several threads at once, each in a
12//! kernel of its own, keeping what each builds beside its file, so that
13//! opening them next builds nothing of them (see [`prebuild`]).
14//!
15//! `geop serve` is the other way in: the same editor the web app runs, as a
16//! process a front end spawns and talks to over stdin/stdout (see
17//! [`serve`]). `geop mcp` is that editor as a Model Context Protocol server,
18//! for an agent to drive (see [`mcp`]). `geop open` serves that editor to
19//! a browser, editing a folder of files (see [`open`]), `geop app` shows
20//! it in a window of its own (see [`app`]), and `geop vscode` installs the
21//! VS Code extension of this release (see [`vscode`]).
22#![cfg_attr(
23    all(feature = "desktop", not(debug_assertions)),
24    windows_subsystem = "windows"
25)]
26
27mod app;
28mod mcp;
29mod open;
30mod release;
31mod session;
32mod vscode;
33
34use clap::{Parser, Subcommand};
35use geop_cad_base::{BuiltPart, disk::DiskCache, outputs, workspace_with};
36use geop_core_editor::editor::Rerun;
37use geop_core_editor::output::Output;
38use geop_core_math::geop_error::{GeopError, GeopResult};
39use geop_core_program::{program::Program, runner::Counts, workspace::FileStore};
40use geop_ops_assembly::solving::{check as check_mates, solve};
41use session::Session;
42use std::{
43    io::{BufRead, Write},
44    path::{Path, PathBuf},
45    process::ExitCode,
46};
47
48#[derive(Parser)]
49#[command(
50    name = "geop",
51    version,
52    about = "The geop CAD kernel on the command line."
53)]
54struct Cli {
55    #[command(subcommand)]
56    command: Command,
57}
58
59#[derive(Subcommand)]
60enum Command {
61    /// Build a program (the JSON the web editor saves) and write its part, in the format the output's extension names.
62    Compile(CompileArgs),
63    /// Write every built-in example (see `geop_cad_base::registry`) as a program and its part, in the default format.
64    Examples(ExamplesArgs),
65    /// Build programs on several threads at once, keeping what each builds beside its file (`.geopc`): opening them next builds nothing of them.
66    Prebuild(PrebuildArgs),
67    /// Run the editor (see `geop_core_editor::editor`) as a host process for a
68    /// front end: one JSON command per line on stdin, one JSON update per
69    /// line on stdout. This is how the VS Code extension drives the kernel.
70    Serve(ServeArgs),
71    /// Run the editor as a Model Context Protocol server over stdin/stdout,
72    /// for an agent to drive: `claude mcp add geop -- geop mcp` (Claude Code)
73    /// or `codex mcp add geop -- geop mcp` (Codex). See
74    /// docs/mcp.md.
75    Mcp(McpArgs),
76    /// Edit the files of a folder in the web editor, in your browser, on
77    /// the kernel running here: `geop open my-project`. The files stay on
78    /// disk, written as they are edited.
79    Open(open::OpenArgs),
80    /// Edit the files of a folder in the editor, in a window of its own:
81    /// `geop app my-project`, or `geop app` to choose the folder. The same
82    /// editor as `geop open`, without the browser.
83    App(app::AppArgs),
84    /// Install the VS Code extension of this version of geop into VS Code
85    /// (or Cursor, or VSCodium), downloaded from its GitHub release.
86    Vscode(vscode::VscodeArgs),
87}
88
89#[derive(clap::Args)]
90struct ServeArgs {
91    /// The folder the files the front end sends are named relative to:
92    /// what they are made into is kept there, beside each, in a `.geopc`
93    /// file (see `geop_core_program::cache`). Kept in memory only, if not
94    /// given.
95    #[arg(long, value_name = "DIR")]
96    cache_root: Option<PathBuf>,
97}
98
99#[derive(clap::Args)]
100struct McpArgs {}
101
102#[derive(clap::Args)]
103struct PrebuildArgs {
104    /// The programs to build, e.g. every `.geop` file of a folder.
105    programs: Vec<PathBuf>,
106    /// How many to build at once: as many as the machine has cores, if not given.
107    #[arg(short, long)]
108    jobs: Option<usize>,
109}
110
111#[derive(clap::Args)]
112struct CompileArgs {
113    /// The program to build, e.g. `part.geop`.
114    program: PathBuf,
115    /// Where to write the part, in the format its extension names — one an
116    /// exporter writes. Defaults to the program's path with the extension
117    /// of the default format (see [`default_output`]).
118    #[arg(short, long)]
119    output: Option<PathBuf>,
120    /// An option of the exporter, `key=value`, the value JSON or else
121    /// text: `-O quality=8`, `-O 'solids=["extrude(hole)"]'`. Repeat for
122    /// several; those the exporter does not take, it ignores.
123    #[arg(short = 'O', long = "option", value_name = "KEY=VALUE")]
124    options: Vec<String>,
125}
126
127#[derive(clap::Args)]
128struct ExamplesArgs {
129    /// Where to write `<name>.geop` and `<name>.stl` for each example.
130    #[arg(short, long, default_value = "examples")]
131    out_dir: PathBuf,
132    /// Only this example, by name (e.g. `handle_with_hole`); repeat for
133    /// several. Every built-in example, if not given.
134    #[arg(long = "only", value_name = "NAME")]
135    only: Vec<String>,
136    /// An option of the exporter, as for `compile`.
137    #[arg(short = 'O', long = "option", value_name = "KEY=VALUE")]
138    options: Vec<String>,
139}
140
141/// The exporter options `pairs` give, `key=value` each, as JSON: each value
142/// read as JSON, or else as text.
143fn options(pairs: &[String]) -> GeopResult<serde_json::Value> {
144    let options = (pairs.iter())
145        .map(|pair| {
146            let (key, value) = pair
147                .split_once('=')
148                .ok_or_else(|| GeopError::new(format!("the option {pair:?} is not `key=value`")))?;
149            let value = serde_json::from_str(value)
150                .unwrap_or_else(|_| serde_json::Value::String(value.to_string()));
151            Ok((key.to_string(), value))
152        })
153        .collect::<GeopResult<serde_json::Map<_, _>>>()?;
154    Ok(serde_json::Value::Object(options))
155}
156
157/// Today's date, `YYYY-MM-DD` (UTC): what an exported file is dated.
158fn today() -> String {
159    let days = std::time::SystemTime::now()
160        .duration_since(std::time::UNIX_EPOCH)
161        .map(|d| (d.as_secs() / 86_400) as i64)
162        .unwrap_or(0);
163    // Days since 1970-01-01 to a civil date (Howard Hinnant's algorithm).
164    let z = days + 719_468;
165    let era = z.div_euclid(146_097);
166    let doe = z.rem_euclid(146_097);
167    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
168    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
169    let mp = (5 * doy + 2) / 153;
170    let day = doy - (153 * mp + 2) / 5 + 1;
171    let month = if mp < 10 { mp + 3 } else { mp - 9 };
172    let year = yoe + era * 400 + i64::from(month <= 2);
173    format!("{year:04}-{month:02}-{day:02}")
174}
175
176/// What a compile wrote, for the report.
177#[derive(Debug)]
178struct Compiled {
179    output: PathBuf,
180    steps: usize,
181    /// How big the file written is.
182    bytes: usize,
183    /// The mates the program's state did not hold, which it was
184    /// solved for before it was written; none if they all held.
185    solved_for: Vec<String>,
186}
187
188/// `program`'s path with its extension replaced by the first format the
189/// first exporter offered writes: `part.geop` becomes `part.stl`.
190fn default_output(program: &Path) -> GeopResult<PathBuf> {
191    let outputs = outputs()?;
192    let format = (outputs.exporters().first())
193        .and_then(|exporter| exporter.formats.first())
194        .ok_or_else(|| GeopError::new("the editor offers no exporter"))?;
195    Ok(program.with_extension(format))
196}
197
198/// The program files on disk, by their paths.
199struct Disk;
200
201impl FileStore for Disk {
202    /// A file that is not UTF-8 throughout — a binary STL, a STEP file
203    /// with a Latin-1 name in it — is held as its bytes (see
204    /// [`geop_core_program::workspace::content`]).
205    fn read(&self, path: &str) -> GeopResult<String> {
206        std::fs::read(path)
207            .map(geop_core_program::workspace::content)
208            .map_err(|e| GeopError::new(format!("reading {path}: {e}")))
209    }
210
211    /// Nothing: a compile only reads what the program places.
212    fn list(&self) -> Vec<String> {
213        Vec::new()
214    }
215}
216
217/// The program at `path`, built, its parts where its mates hold them, and
218/// the mates the program's state did not hold. A stale state — a file it
219/// places changed since it was saved — is solved for here, not in the
220/// file: that is the editor's to write.
221fn build(path: &Path) -> GeopResult<(Program, BuiltPart, Vec<String>)> {
222    let path = path.to_string_lossy();
223    // A standard part — `std:iso4032_hex_nut.geop` — builds too.
224    let workspace = workspace_with(Disk, DiskCache::cache(DiskCache::of(PathBuf::new())));
225    let mut program = Program::from_json(&workspace.files().read(&path)?)?;
226    let mut built = geop_cad_base::build(&program, &path, workspace.clone())?;
227    let report = check_mates(&built.part.current())?;
228    if !report.converged {
229        let mut rerun = Rerun {
230            runner: &built.runner,
231            part: &mut built.part,
232            program: &program,
233        };
234        let (moved, _) = solve(&mut rerun, &program.state, &[], None, true, None)?;
235        program.state.extend(moved);
236        program.run(&built.runner, &mut built.part)?;
237    }
238    // What it built is kept beside the files, for the next build to read.
239    if let Some(cache) = geop_core_program::workspace::Workspace::cache(&*workspace) {
240        cache.flush();
241    }
242    Ok((program, built, report.failed))
243}
244
245/// Builds `args.programs`, `args.jobs` at once, each on a thread of its own
246/// in a kernel of its own — a kernel shares nothing with another thread —
247/// keeping what each builds beside its file ([`build`]): the cache files
248/// are what the threads share. Says, of each, what its build did, or why it
249/// failed.
250fn prebuild(args: &PrebuildArgs) -> Vec<(PathBuf, GeopResult<Counts>)> {
251    let jobs = args.jobs.unwrap_or_else(|| {
252        std::thread::available_parallelism().map_or(1, std::num::NonZeroUsize::get)
253    });
254    let next = std::sync::atomic::AtomicUsize::new(0);
255    let built = std::sync::Mutex::new(Vec::new());
256    std::thread::scope(|scope| {
257        for _ in 0..jobs.clamp(1, args.programs.len().max(1)) {
258            scope.spawn(|| {
259                loop {
260                    let at = next.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
261                    let Some(path) = args.programs.get(at) else {
262                        break;
263                    };
264                    let counts = build(path).map(|(_, part, _)| part.runner.counts());
265                    if let Ok(mut built) = built.lock() {
266                        built.push((path.clone(), counts));
267                    }
268                }
269            });
270        }
271    });
272    let mut built = built.into_inner().unwrap_or_default();
273    built.sort_by(|a, b| a.0.cmp(&b.0));
274    built
275}
276
277fn compile(args: &CompileArgs) -> GeopResult<Compiled> {
278    let io_err = |what: &str, path: &Path| {
279        let what = what.to_string();
280        let path = path.display().to_string();
281        move |e: std::io::Error| GeopError::new(format!("{what} {path}: {e}"))
282    };
283    let (program, mut built_part, solved_for) = build(&args.program)?;
284
285    let output = match &args.output {
286        Some(output) => output.clone(),
287        None => default_output(&args.program)?,
288    };
289    // The exporter of the output's format, told the options of the command
290    // line: those it does not take, it ignores.
291    let format = (output.extension())
292        .and_then(|e| e.to_str())
293        .ok_or_else(|| GeopError::new(format!("{} names no format", output.display())))?;
294    let outputs = outputs()?;
295    let exporter = outputs.exporter_for(format)?;
296    let options = options(&args.options)?;
297    let today = today();
298    let mut built = Output {
299        runner: &built_part.runner,
300        part: &mut built_part.part,
301        file: &program,
302        editing: None,
303        date: &today,
304    };
305    let export = exporter.export(&mut built, Some(format), &options)?;
306    std::fs::write(&output, export.bytes()).map_err(io_err("writing", &output))?;
307
308    Ok(Compiled {
309        output,
310        steps: program.steps.len(),
311        bytes: export.bytes().len(),
312        solved_for,
313    })
314}
315
316/// Write every built-in example as `<out_dir>/<name>.geop` and its part
317/// `<out_dir>/<name>.stl`, in the default format (see [`default_output`])
318/// — and every example of several files as those files in
319/// `<out_dir>/<name>/`, its first one compiled — via [`compile`], so an
320/// example's part is written exactly the way any other program's would be.
321fn export_examples(args: &ExamplesArgs) -> GeopResult<Vec<Compiled>> {
322    let dir_err = |dir: &Path| {
323        let dir = dir.display().to_string();
324        move |e: std::io::Error| GeopError::new(format!("creating {dir}: {e}"))
325    };
326    std::fs::create_dir_all(&args.out_dir).map_err(dir_err(&args.out_dir))?;
327    let offered = geop_cad_base::registry()?;
328    let singles: Vec<&str> = offered.example_names().collect();
329    let workspaces: Vec<&str> = offered.workspace_example_names().collect();
330    let known = || -> Vec<&str> { singles.iter().chain(&workspaces).copied().collect() };
331    if let Some(unknown) = args.only.iter().find(|n| !known().contains(&n.as_str())) {
332        return Err(GeopError::new(format!(
333            "no example named {unknown:?}; the built-in examples are: {}",
334            known().join(", ")
335        )));
336    }
337    let chosen = |name: &str| args.only.is_empty() || args.only.iter().any(|n| n == name);
338    let write = |path: &Path, program: &Program| {
339        std::fs::write(path, program.to_json()?)
340            .map_err(|e| GeopError::new(format!("writing {}: {e}", path.display())))
341    };
342    let compile_to = |program: PathBuf, output: PathBuf| {
343        let output = default_output(&output)?;
344        compile(&CompileArgs {
345            program,
346            output: Some(output),
347            options: args.options.clone(),
348        })
349    };
350    let mut compiled = Vec::new();
351    for name in singles.iter().filter(|n| chosen(n)) {
352        let path = args.out_dir.join(format!("{name}.geop"));
353        write(&path, &offered.example(name)?)?;
354        let output = args.out_dir.join(name);
355        compiled.push(
356            compile_to(path, output)
357                .map_err(|e| e.with_context(format!("export_examples(name={name})")))?,
358        );
359    }
360    for name in workspaces.iter().filter(|n| chosen(n)) {
361        let dir = args.out_dir.join(name);
362        std::fs::create_dir_all(&dir).map_err(dir_err(&dir))?;
363        let files = offered.workspace_example(name)?;
364        for (file, program) in &files {
365            write(&dir.join(file), program)?;
366        }
367        let (main, _) = files.first().expect("an example has files");
368        let output = args.out_dir.join(name);
369        compiled.push(
370            compile_to(dir.join(main), output)
371                .map_err(|e| e.with_context(format!("export_examples(name={name})")))?,
372        );
373    }
374    Ok(compiled)
375}
376
377/// Run one [`Session`] until stdin closes: every line is a command, and the
378/// answer is one line (see [`session`]). After a panic the process ends:
379/// the front end starts another with the program it holds
380/// (`vscode-extension/src/server.ts`), rather than waiting for an answer
381/// that never comes.
382fn serve(args: &ServeArgs) -> GeopResult<()> {
383    let io_err = |e: std::io::Error| GeopError::new(format!("serving: {e}"));
384    let mut session = Session::new(args.cache_root.clone());
385    let stdin = std::io::stdin().lock();
386    let mut stdout = std::io::stdout().lock();
387    for line in stdin.lines() {
388        let line = line.map_err(io_err)?;
389        if line.trim().is_empty() {
390            continue;
391        }
392        let answer = session.answer(&line);
393        writeln!(stdout, "{}", answer.reply)
394            .and_then(|()| stdout.flush())
395            .map_err(io_err)?;
396        if answer.crashed {
397            return Err(GeopError::new("the kernel panicked"));
398        }
399    }
400    Ok(())
401}
402
403fn main() -> ExitCode {
404    #[cfg(not(feature = "desktop"))]
405    let cli = Cli::parse();
406    #[cfg(feature = "desktop")]
407    let cli = Cli::parse_from(app::desktop_args(std::env::args_os()));
408    let result = match cli.command {
409        Command::Serve(args) => serve(&args),
410        Command::Mcp(McpArgs {}) => mcp::serve(),
411        Command::Open(args) => open::open(&args),
412        Command::App(args) => app::run(&args),
413        Command::Vscode(args) => vscode::install(&args),
414        Command::Compile(args) => compile(&args).map(|c| {
415            if !c.solved_for.is_empty() {
416                eprintln!(
417                    "warning: the program's parts are not where its mates hold them ({}); compiled where they do",
418                    c.solved_for.join(", ")
419                );
420            }
421            eprintln!(
422                "{} steps, {} bytes -> {}",
423                c.steps,
424                c.bytes,
425                c.output.display()
426            );
427        }),
428        Command::Prebuild(args) => {
429            let built = prebuild(&args);
430            let failed = built.iter().filter(|(_, b)| b.is_err()).count();
431            for (path, counts) in &built {
432                match counts {
433                    Ok(c) => eprintln!(
434                        "{}: {} made ({} of them kept), {} read back",
435                        path.display(),
436                        c.makes,
437                        c.kept_makes,
438                        c.restored
439                    ),
440                    Err(e) => eprintln!("{}: {e}", path.display()),
441                }
442            }
443            match failed {
444                0 => Ok(()),
445                n => Err(GeopError::new(format!("{n} program(s) did not build"))),
446            }
447        }
448        Command::Examples(args) => export_examples(&args).map(|compiled| {
449            for c in &compiled {
450                eprintln!("{} bytes -> {}", c.bytes, c.output.display());
451            }
452            eprintln!(
453                "{} example(s) -> {}",
454                compiled.len(),
455                args.out_dir.display()
456            );
457        }),
458    };
459    match result {
460        Ok(()) => ExitCode::SUCCESS,
461        Err(e) => {
462            eprintln!("error: {e}");
463            // A window's program has no terminal to say it in.
464            #[cfg(feature = "desktop")]
465            rfd::MessageDialog::new()
466                .set_level(rfd::MessageLevel::Error)
467                .set_title("Geop could not start")
468                .set_description(e.root_message())
469                .show();
470            ExitCode::FAILURE
471        }
472    }
473}
474
475#[cfg(test)]
476mod tests {
477    use super::*;
478
479    /// Programs built on two threads at once are kept beside their files:
480    /// built again, here, they make nothing they kept.
481    #[test]
482    fn programs_prebuilt_on_threads_are_kept() {
483        let dir = scratch("prebuild");
484        let registry = geop_cad_base::registry().unwrap();
485        let programs: Vec<PathBuf> = ["box_with_drill_hole", "parametric_plate", "pin"]
486            .iter()
487            .map(|name| {
488                let path = dir.join(format!("{name}.geop"));
489                let text = registry.example(name).unwrap().to_json().unwrap();
490                std::fs::write(&path, text).unwrap();
491                path
492            })
493            .collect();
494        let args = PrebuildArgs {
495            programs: programs.clone(),
496            jobs: Some(2),
497        };
498        for (path, counts) in prebuild(&args) {
499            let counts = counts.unwrap();
500            assert!(counts.kept_makes > 0, "{}: {counts:?}", path.display());
501        }
502        for path in &programs {
503            let (_, part, _) = build(path).unwrap();
504            let counts = part.runner.counts();
505            assert_eq!(counts.kept_makes, 0, "{}: {counts:?}", path.display());
506            assert!(counts.restored > 0);
507        }
508        std::fs::remove_dir_all(&dir).unwrap();
509    }
510
511    /// A fresh directory for one test's files.
512    fn scratch(test: &str) -> PathBuf {
513        let dir = std::env::temp_dir().join(format!("geop-cli-{test}-{}", std::process::id()));
514        std::fs::create_dir_all(&dir).unwrap();
515        dir
516    }
517
518    /// How many triangles the binary STL file `compiled` wrote holds: what
519    /// its header says, checked against its size.
520    fn triangles(compiled: &Compiled) -> usize {
521        let bytes = std::fs::read(&compiled.output).unwrap();
522        let count = u32::from_le_bytes(bytes[80..84].try_into().unwrap()) as usize;
523        assert_eq!(
524            bytes.len(),
525            84 + 50 * count,
526            "{}",
527            compiled.output.display()
528        );
529        count
530    }
531
532    fn args(program: PathBuf) -> CompileArgs {
533        CompileArgs {
534            program,
535            output: None,
536            options: Vec::new(),
537        }
538    }
539
540    #[test]
541    fn default_output_replaces_the_program_extension() {
542        let output = |path: &str| default_output(Path::new(path)).unwrap();
543        assert_eq!(output("a/part.geop"), Path::new("a/part.stl"));
544        assert_eq!(output("part.json"), Path::new("part.stl"));
545        assert_eq!(output("part"), Path::new("part.stl"));
546    }
547
548    #[test]
549    fn options_are_json_or_else_text() {
550        let pairs = ["quality=8", r#"solids=["a(b)"]"#, "name=x=y"].map(String::from);
551        assert_eq!(
552            options(&pairs).unwrap(),
553            serde_json::json!({"quality": 8, "solids": ["a(b)"], "name": "x=y"})
554        );
555        assert!(options(&["quality".into()]).is_err());
556    }
557
558    /// A command that cannot be read is answered, not fatal: the front end
559    /// is waiting for exactly one line per line it sent.
560    #[test]
561    fn serve_answers_every_command_with_one_line() {
562        let mut editor = geop_cad_base::editor();
563        let shown = editor.handle_json(r#"{"command": "show"}"#).unwrap();
564        let shown: serde_json::Value = serde_json::from_str(&shown).unwrap();
565        assert!(shown["error"].is_null(), "{shown}");
566        assert!(
567            shown["program"]["examples"]
568                .as_array()
569                .is_some_and(|e| !e.is_empty())
570        );
571        assert!(editor.handle_json("not json").is_err());
572    }
573
574    #[test]
575    #[ignore = "slow: 7 s — run with `cargo test -- --ignored`"]
576    fn compiles_every_example() {
577        let dir = scratch("examples");
578        let offered = geop_cad_base::registry().unwrap();
579        for name in offered.example_names() {
580            let path = dir.join(format!("{name}.geop"));
581            let program = offered.example(name).unwrap();
582            std::fs::write(&path, program.to_json().unwrap()).unwrap();
583            let compiled = compile(&args(path)).unwrap();
584            assert_eq!(compiled.output, dir.join(format!("{name}.stl")));
585            assert!(triangles(&compiled) > 0, "{name}: no triangles");
586        }
587    }
588
589    #[test]
590    #[ignore = "slow: 10 s — run with `cargo test -- --ignored`"]
591    fn export_examples_writes_every_example_as_json_and_stl() {
592        let dir = scratch("export-examples");
593        let compiled = export_examples(&ExamplesArgs {
594            out_dir: dir.clone(),
595            only: Vec::new(),
596            options: Vec::new(),
597        })
598        .unwrap();
599        let offered = geop_cad_base::registry().unwrap();
600        assert_eq!(
601            compiled.len(),
602            offered.example_names().count() + offered.workspace_example_names().count()
603        );
604        for name in offered.example_names() {
605            assert!(dir.join(format!("{name}.geop")).is_file(), "{name}");
606            assert!(dir.join(format!("{name}.stl")).is_file(), "{name}");
607        }
608        for name in offered.workspace_example_names() {
609            for (file, _) in offered.workspace_example(name).unwrap() {
610                assert!(dir.join(name).join(file).is_file(), "{name}/{file}");
611            }
612            assert!(dir.join(format!("{name}.stl")).is_file(), "{name}");
613        }
614    }
615
616    #[test]
617    fn export_examples_only_writes_the_named_ones() {
618        let dir = scratch("export-examples-only");
619        let compiled = export_examples(&ExamplesArgs {
620            out_dir: dir.clone(),
621            only: vec!["cross_drilled_shaft".into(), "luggage_tag".into()],
622            options: Vec::new(),
623        })
624        .unwrap();
625        assert_eq!(compiled.len(), 2);
626        assert!(dir.join("cross_drilled_shaft.stl").is_file());
627        assert!(dir.join("luggage_tag.stl").is_file());
628        assert!(!dir.join("box_with_drill_hole.stl").exists());
629    }
630
631    #[test]
632    fn export_examples_only_names_the_known_ones_when_unknown() {
633        let dir = scratch("export-examples-unknown");
634        let err = export_examples(&ExamplesArgs {
635            out_dir: dir,
636            only: vec!["not_a_real_example".into()],
637            options: Vec::new(),
638        })
639        .unwrap_err();
640        assert!(err.to_string().contains("luggage_tag"), "{err}");
641    }
642
643    #[test]
644    fn open_app_and_vscode_take_their_arguments() {
645        let parse = |args: &[&str]| Cli::try_parse_from([&["geop"], args].concat());
646        let Command::Open(open) = parse(&["open"]).unwrap().command else {
647            panic!("not open")
648        };
649        assert_eq!(
650            (open.path, open.port, open.no_browser),
651            (PathBuf::from("."), None, false)
652        );
653        let Command::Open(open) = parse(&["open", "proj", "--port", "8080", "--no-browser"])
654            .unwrap()
655            .command
656        else {
657            panic!("not open")
658        };
659        assert_eq!(
660            (open.path, open.port, open.no_browser),
661            (PathBuf::from("proj"), Some(8080), true)
662        );
663        assert!(parse(&["open", "--port", "70000"]).is_err());
664        let Command::App(app) = parse(&["app"]).unwrap().command else {
665            panic!("not app")
666        };
667        assert_eq!(app.path, None);
668        let Command::App(app) = parse(&["app", "proj/part.geop"]).unwrap().command else {
669            panic!("not app")
670        };
671        assert_eq!(app.path, Some(PathBuf::from("proj/part.geop")));
672        assert!(parse(&["app", "a", "b"]).is_err());
673        let Command::Vscode(vscode) = parse(&["vscode"]).unwrap().command else {
674            panic!("not vscode")
675        };
676        assert_eq!(vscode.editor, None);
677        let Command::Vscode(vscode) = parse(&["vscode", "--editor", "cursor"]).unwrap().command
678        else {
679            panic!("not vscode")
680        };
681        assert_eq!(vscode.editor.as_deref(), Some("cursor"));
682    }
683
684    #[test]
685    fn today_is_a_date() {
686        let today = today();
687        assert_eq!(today.len(), 10, "{today}");
688        assert_eq!(today.matches('-').count(), 2, "{today}");
689    }
690}