Skip to main content

logicaffeine_cli/
cli.rs

1//! Phase 37/39: LOGOS CLI (largo)
2//!
3//! Command-line interface for the LOGOS build system and package registry.
4//!
5//! This module provides the command-line argument parsing and dispatch logic
6//! for the `largo` CLI tool. It handles all user-facing commands including
7//! project scaffolding, building, running, and package registry operations.
8//!
9//! # Architecture
10//!
11//! The CLI is built on [`clap`] for argument parsing with derive macros.
12//! Each command variant in [`Commands`] maps to a handler function that
13//! performs the actual work.
14//!
15//! # Examples
16//!
17//! ```bash
18//! # Create a new project
19//! largo new my_project
20//!
21//! # Build and run
22//! cd my_project
23//! largo run
24//!
25//! # Publish to registry
26//! largo login
27//! largo publish
28//! ```
29
30use clap::{Parser, Subcommand};
31use std::env;
32use std::fs;
33use std::io::{self, Write};
34use std::path::PathBuf;
35
36use crate::compile::compile_project;
37use crate::project::build::{self, find_project_root, BuildConfig};
38use crate::project::manifest::Manifest;
39use crate::project::credentials::{Credentials, get_token};
40use crate::project::registry::{
41    RegistryClient, PublishMetadata, create_tarball, is_git_dirty,
42};
43
44/// Command-line interface for the LOGOS build tool.
45///
46/// The `Cli` struct is the top-level argument parser for `largo`. It delegates
47/// to the [`Commands`] enum for subcommand handling.
48///
49/// # Usage
50///
51/// Typically invoked via [`run_cli`] which parses arguments and dispatches
52/// to the appropriate handler:
53///
54/// ```no_run
55/// use logicaffeine_cli::cli::run_cli;
56///
57/// if let Err(e) = run_cli() {
58///     eprintln!("Error: {}", e);
59///     std::process::exit(1);
60/// }
61/// ```
62#[derive(Parser)]
63#[command(name = "largo")]
64#[command(about = "The LOGOS build tool", long_about = None)]
65#[command(version)]
66pub struct Cli {
67    /// The subcommand to execute.
68    #[command(subcommand)]
69    pub command: Commands,
70}
71
72/// Available CLI subcommands.
73///
74/// Each variant represents a distinct operation that `largo` can perform.
75/// Commands are grouped into three categories:
76///
77/// ## Project Management
78/// - [`New`][Commands::New] - Create a new project in a new directory
79/// - [`Init`][Commands::Init] - Initialize a project in the current directory
80///
81/// ## Build & Run
82/// - [`Build`][Commands::Build] - Compile the project
83/// - [`Run`][Commands::Run] - Build and execute
84/// - [`Check`][Commands::Check] - Type-check without building
85/// - [`Verify`][Commands::Verify] - Run Z3 static verification
86///
87/// ## Package Registry
88/// - [`Publish`][Commands::Publish] - Upload package to registry
89/// - [`Login`][Commands::Login] - Authenticate with registry
90/// - [`Logout`][Commands::Logout] - Remove stored credentials
91#[derive(Subcommand)]
92pub enum Commands {
93    /// Create a new LOGOS project in a new directory.
94    ///
95    /// Scaffolds a complete project structure including:
96    /// - `Largo.toml` manifest file
97    /// - `src/main.lg` entry point with a "Hello, world!" example
98    /// - `.gitignore` configured for LOGOS projects
99    ///
100    /// # Example
101    ///
102    /// ```bash
103    /// largo new my_project
104    /// cd my_project
105    /// largo run
106    /// ```
107    New {
108        /// The project name, used for the directory and package name.
109        name: String,
110    },
111
112    /// Initialize a LOGOS project in the current directory.
113    ///
114    /// Similar to [`New`][Commands::New] but works in an existing directory.
115    /// Creates the manifest and source structure without creating a new folder.
116    ///
117    /// # Example
118    ///
119    /// ```bash
120    /// mkdir my_project && cd my_project
121    /// largo init
122    /// ```
123    Init {
124        /// Project name. If omitted, uses the current directory name.
125        #[arg(long)]
126        name: Option<String>,
127    },
128
129    /// Build the current project.
130    ///
131    /// Compiles the LOGOS source to Rust, then invokes `cargo build` on the
132    /// generated code. The resulting binary is placed in `target/debug/` or
133    /// `target/release/` depending on the mode.
134    ///
135    /// # Verification
136    ///
137    /// When `--verify` is passed, the build process includes Z3 static
138    /// verification of logical constraints. This requires:
139    /// - A Pro+ license (via `--license` or `LOGOS_LICENSE` env var)
140    /// - The `verification` feature enabled at build time
141    ///
142    /// # Example
143    ///
144    /// ```bash
145    /// largo build              # Debug build
146    /// largo build --release    # Release build with optimizations
147    /// largo build --verify     # Build with Z3 verification
148    /// ```
149    Build {
150        /// Build with optimizations enabled.
151        #[arg(long, short)]
152        release: bool,
153
154        /// Run Z3 static verification after compilation.
155        /// Requires a Pro+ license.
156        #[arg(long)]
157        verify: bool,
158
159        /// License key for verification.
160        /// Can also be set via the `LOGOS_LICENSE` environment variable.
161        #[arg(long)]
162        license: Option<String>,
163
164        /// Build as a library instead of an executable.
165        /// Generates `lib.rs` with `crate-type = ["cdylib"]` instead of a binary.
166        #[arg(long)]
167        lib: bool,
168
169        /// Target triple for cross-compilation.
170        /// Use "wasm" as shorthand for "wasm32-unknown-unknown".
171        #[arg(long)]
172        target: Option<String>,
173    },
174
175    /// Run Z3 static verification without building.
176    ///
177    /// Performs formal verification of logical constraints in the project
178    /// using the Z3 SMT solver. This catches logical errors that would be
179    /// impossible to detect through testing alone.
180    ///
181    /// Requires a Pro+ license.
182    ///
183    /// # Example
184    ///
185    /// ```bash
186    /// largo verify --license sub_xxxxx
187    /// # Or with environment variable:
188    /// export LOGOS_LICENSE=sub_xxxxx
189    /// largo verify
190    /// ```
191    Verify {
192        /// License key for verification.
193        /// Can also be set via the `LOGOS_LICENSE` environment variable.
194        #[arg(long)]
195        license: Option<String>,
196    },
197
198    /// Build and run the current project.
199    ///
200    /// Equivalent to `largo build` followed by executing the resulting binary.
201    /// The exit code of the built program is propagated.
202    ///
203    /// With `--interpret`, skips Rust compilation and uses the tree-walking
204    /// interpreter for sub-second feedback during development.
205    ///
206    /// # Example
207    ///
208    /// ```bash
209    /// largo run              # Debug mode (compile to Rust)
210    /// largo run --release    # Release mode
211    /// largo run --interpret  # Interpret directly (no compilation)
212    /// ```
213    Run {
214        /// Build with optimizations enabled.
215        #[arg(long, short)]
216        release: bool,
217
218        /// Run using the interpreter instead of compiling to Rust.
219        /// Provides sub-second feedback but lacks full Rust performance.
220        #[arg(long, short)]
221        interpret: bool,
222
223        /// Arguments to pass to the program.
224        #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
225        args: Vec<String>,
226    },
227
228    /// Check the project for errors without producing a binary.
229    ///
230    /// Parses and type-checks the LOGOS source without invoking the full
231    /// build pipeline. Useful for quick validation during development.
232    ///
233    /// # Example
234    ///
235    /// ```bash
236    /// largo check
237    /// ```
238    Check,
239
240    /// Publish the package to the LOGOS registry.
241    ///
242    /// Packages the project as a tarball and uploads it to the specified
243    /// registry. Requires authentication via `largo login`.
244    ///
245    /// # Pre-flight Checks
246    ///
247    /// Before publishing, the command verifies:
248    /// - The entry point exists
249    /// - No uncommitted git changes (unless `--allow-dirty`)
250    /// - Valid authentication token
251    ///
252    /// # Example
253    ///
254    /// ```bash
255    /// largo publish              # Publish to default registry
256    /// largo publish --dry-run    # Validate without uploading
257    /// ```
258    Publish {
259        /// Registry URL. Defaults to `registry.logicaffeine.com`.
260        #[arg(long)]
261        registry: Option<String>,
262
263        /// Perform all validation without actually uploading.
264        /// Useful for testing the publish process.
265        #[arg(long)]
266        dry_run: bool,
267
268        /// Allow publishing with uncommitted git changes.
269        /// By default, publishing requires a clean working directory.
270        #[arg(long)]
271        allow_dirty: bool,
272    },
273
274    /// Authenticate with the package registry.
275    ///
276    /// Stores an API token for the specified registry. The token is saved
277    /// in `~/.config/logos/credentials.toml` with restricted permissions.
278    ///
279    /// # Token Acquisition
280    ///
281    /// Tokens can be obtained from the registry's web interface:
282    /// 1. Visit `{registry}/auth/github` to authenticate
283    /// 2. Generate an API token from your profile
284    /// 3. Provide it via `--token` or interactive prompt
285    ///
286    /// # Example
287    ///
288    /// ```bash
289    /// largo login                       # Interactive prompt
290    /// largo login --token tok_xxxxx     # Non-interactive
291    /// ```
292    Login {
293        /// Registry URL. Defaults to `registry.logicaffeine.com`.
294        #[arg(long)]
295        registry: Option<String>,
296
297        /// API token. If omitted, prompts for input on stdin.
298        #[arg(long)]
299        token: Option<String>,
300    },
301
302    /// Remove stored credentials for a registry.
303    ///
304    /// Deletes the authentication token from the local credentials file.
305    ///
306    /// # Example
307    ///
308    /// ```bash
309    /// largo logout
310    /// ```
311    Logout {
312        /// Registry URL. Defaults to `registry.logicaffeine.com`.
313        #[arg(long)]
314        registry: Option<String>,
315    },
316}
317
318/// Parse CLI arguments and execute the corresponding command.
319///
320/// This is the main entry point for the `largo` CLI. It parses command-line
321/// arguments using [`clap`], then dispatches to the appropriate handler
322/// function based on the subcommand.
323///
324/// # Errors
325///
326/// Returns an error if:
327/// - The project structure is invalid (missing `Largo.toml`)
328/// - File system operations fail
329/// - Build or compilation fails
330/// - Registry operations fail (authentication, network, etc.)
331///
332/// # Example
333///
334/// ```no_run
335/// use logicaffeine_cli::cli::run_cli;
336///
337/// fn main() {
338///     if let Err(e) = run_cli() {
339///         eprintln!("Error: {}", e);
340///         std::process::exit(1);
341///     }
342/// }
343/// ```
344pub fn run_cli() -> Result<(), Box<dyn std::error::Error>> {
345    let cli = Cli::parse();
346
347    match cli.command {
348        Commands::New { name } => cmd_new(&name),
349        Commands::Init { name } => cmd_init(name.as_deref()),
350        Commands::Build { release, verify, license, lib, target } => cmd_build(release, verify, license, lib, target),
351        Commands::Run { interpret, .. } if interpret => cmd_run_interpret(),
352        Commands::Run { release, args, .. } => cmd_run(release, &args),
353        Commands::Check => cmd_check(),
354        Commands::Verify { license } => cmd_verify(license),
355        Commands::Publish { registry, dry_run, allow_dirty } => {
356            cmd_publish(registry.as_deref(), dry_run, allow_dirty)
357        }
358        Commands::Login { registry, token } => cmd_login(registry.as_deref(), token),
359        Commands::Logout { registry } => cmd_logout(registry.as_deref()),
360    }
361}
362
363fn cmd_new(name: &str) -> Result<(), Box<dyn std::error::Error>> {
364    let project_dir = PathBuf::from(name);
365
366    if project_dir.exists() {
367        return Err(format!("Directory '{}' already exists", project_dir.display()).into());
368    }
369
370    // Create project structure
371    fs::create_dir_all(&project_dir)?;
372    fs::create_dir_all(project_dir.join("src"))?;
373
374    // Write Largo.toml
375    let manifest = Manifest::new(name);
376    fs::write(project_dir.join("Largo.toml"), manifest.to_toml()?)?;
377
378    // Write src/main.lg
379    let main_lg = r#"# Main
380
381A simple LOGOS program.
382
383## Main
384
385Show "Hello, world!".
386"#;
387    fs::write(project_dir.join("src/main.lg"), main_lg)?;
388
389    // Write .gitignore
390    fs::write(project_dir.join(".gitignore"), "/target\n")?;
391
392    println!("Created LOGOS project '{}'", name);
393    println!("  cd {}", project_dir.display());
394    println!("  largo run");
395
396    Ok(())
397}
398
399fn cmd_init(name: Option<&str>) -> Result<(), Box<dyn std::error::Error>> {
400    let current_dir = env::current_dir()?;
401    let project_name = name
402        .map(String::from)
403        .or_else(|| {
404            current_dir
405                .file_name()
406                .and_then(|n| n.to_str())
407                .map(String::from)
408        })
409        .unwrap_or_else(|| "project".to_string());
410
411    if current_dir.join("Largo.toml").exists() {
412        return Err("Largo.toml already exists".into());
413    }
414
415    // Create src directory if needed
416    fs::create_dir_all(current_dir.join("src"))?;
417
418    // Write Largo.toml
419    let manifest = Manifest::new(&project_name);
420    fs::write(current_dir.join("Largo.toml"), manifest.to_toml()?)?;
421
422    // Write src/main.lg if it doesn't exist
423    let main_path = current_dir.join("src/main.lg");
424    if !main_path.exists() {
425        let main_lg = r#"# Main
426
427A simple LOGOS program.
428
429## Main
430
431Show "Hello, world!".
432"#;
433        fs::write(main_path, main_lg)?;
434    }
435
436    println!("Initialized LOGOS project '{}'", project_name);
437
438    Ok(())
439}
440
441fn cmd_build(
442    release: bool,
443    verify: bool,
444    license: Option<String>,
445    lib: bool,
446    target: Option<String>,
447) -> Result<(), Box<dyn std::error::Error>> {
448    let current_dir = env::current_dir()?;
449    let project_root =
450        find_project_root(&current_dir).ok_or("Not in a LOGOS project (Largo.toml not found)")?;
451
452    // Run verification if requested
453    if verify {
454        run_verification(&project_root, license.as_deref())?;
455    }
456
457    let config = BuildConfig {
458        project_dir: project_root,
459        release,
460        lib_mode: lib,
461        target,
462    };
463
464    let result = build::build(config)?;
465
466    let mode = if release { "release" } else { "debug" };
467    println!("Built {} [{}]", result.binary_path.display(), mode);
468
469    Ok(())
470}
471
472fn cmd_verify(license: Option<String>) -> Result<(), Box<dyn std::error::Error>> {
473    let current_dir = env::current_dir()?;
474    let project_root =
475        find_project_root(&current_dir).ok_or("Not in a LOGOS project (Largo.toml not found)")?;
476
477    run_verification(&project_root, license.as_deref())?;
478    println!("Verification passed");
479    Ok(())
480}
481
482#[cfg(feature = "verification")]
483fn run_verification(
484    project_root: &std::path::Path,
485    license: Option<&str>,
486) -> Result<(), Box<dyn std::error::Error>> {
487    use logicaffeine_verify::{LicenseValidator, Verifier};
488
489    // Get license key from argument or environment
490    let license_key = license
491        .map(String::from)
492        .or_else(|| env::var("LOGOS_LICENSE").ok());
493
494    let license_key = license_key.ok_or(
495        "Verification requires a license key.\n\
496         Use --license <key> or set LOGOS_LICENSE environment variable.\n\
497         Get a license at https://logicaffeine.com/pricing",
498    )?;
499
500    // Validate license
501    println!("Validating license...");
502    let validator = LicenseValidator::new();
503    let plan = validator.validate(&license_key)?;
504    println!("License valid ({})", plan);
505
506    // Load and parse the project
507    let manifest = Manifest::load(project_root)?;
508    let entry_path = project_root.join(&manifest.package.entry);
509    let source = fs::read_to_string(&entry_path)?;
510
511    // For now, just verify that Z3 works
512    // TODO: Implement full AST encoding in Phase 2
513    println!("Running Z3 verification...");
514    let verifier = Verifier::new();
515
516    // Basic smoke test - verify that true is valid
517    verifier.check_bool(true)?;
518
519    Ok(())
520}
521
522#[cfg(not(feature = "verification"))]
523fn run_verification(
524    _project_root: &std::path::Path,
525    _license: Option<&str>,
526) -> Result<(), Box<dyn std::error::Error>> {
527    Err("Verification requires the 'verification' feature.\n\
528         Rebuild with: cargo build --features verification"
529        .into())
530}
531
532fn cmd_run(release: bool, args: &[String]) -> Result<(), Box<dyn std::error::Error>> {
533    let current_dir = env::current_dir()?;
534    let project_root =
535        find_project_root(&current_dir).ok_or("Not in a LOGOS project (Largo.toml not found)")?;
536
537    let config = BuildConfig {
538        project_dir: project_root,
539        release,
540        lib_mode: false,
541        target: None,
542    };
543
544    let result = build::build(config)?;
545    let exit_code = build::run(&result, args)?;
546
547    if exit_code != 0 {
548        std::process::exit(exit_code);
549    }
550
551    Ok(())
552}
553
554fn cmd_run_interpret() -> Result<(), Box<dyn std::error::Error>> {
555    let current_dir = env::current_dir()?;
556    let project_root =
557        find_project_root(&current_dir).ok_or("Not in a LOGOS project (Largo.toml not found)")?;
558
559    let manifest = Manifest::load(&project_root)?;
560    let entry_path = project_root.join(&manifest.package.entry);
561    let source = fs::read_to_string(&entry_path)?;
562
563    let result = futures::executor::block_on(logicaffeine_compile::interpret_for_ui(&source));
564
565    for line in &result.lines {
566        println!("{}", line);
567    }
568
569    if let Some(err) = result.error {
570        eprintln!("{}", err);
571        std::process::exit(1);
572    }
573
574    Ok(())
575}
576
577fn cmd_check() -> Result<(), Box<dyn std::error::Error>> {
578    let current_dir = env::current_dir()?;
579    let project_root =
580        find_project_root(&current_dir).ok_or("Not in a LOGOS project (Largo.toml not found)")?;
581
582    let manifest = Manifest::load(&project_root)?;
583    let entry_path = project_root.join(&manifest.package.entry);
584
585    // Just compile to Rust without building (discard output, only care about success)
586    let _ = compile_project(&entry_path)?;
587
588    println!("Check passed");
589    Ok(())
590}
591
592// ============================================================
593// Phase 39: Registry Commands
594// ============================================================
595
596fn cmd_publish(
597    registry: Option<&str>,
598    dry_run: bool,
599    allow_dirty: bool,
600) -> Result<(), Box<dyn std::error::Error>> {
601    let current_dir = env::current_dir()?;
602    let project_root =
603        find_project_root(&current_dir).ok_or("Not in a LOGOS project (Largo.toml not found)")?;
604
605    // Load manifest
606    let manifest = Manifest::load(&project_root)?;
607    let name = &manifest.package.name;
608    let version = &manifest.package.version;
609
610    println!("Packaging {} v{}", name, version);
611
612    // Determine registry URL
613    let registry_url = registry.unwrap_or(RegistryClient::default_url());
614
615    // Get authentication token
616    let token = get_token(registry_url).ok_or_else(|| {
617        format!(
618            "No authentication token found for {}.\n\
619             Run 'largo login' or set LOGOS_TOKEN environment variable.",
620            registry_url
621        )
622    })?;
623
624    // Verify the package
625    let entry_path = project_root.join(&manifest.package.entry);
626    if !entry_path.exists() {
627        return Err(format!(
628            "Entry point '{}' not found",
629            manifest.package.entry
630        ).into());
631    }
632
633    // Check for uncommitted changes
634    if !allow_dirty && is_git_dirty(&project_root) {
635        return Err(
636            "Working directory has uncommitted changes.\n\
637             Use --allow-dirty to publish anyway.".into()
638        );
639    }
640
641    // Create tarball
642    println!("Creating package tarball...");
643    let tarball = create_tarball(&project_root)?;
644    println!("  Package size: {} bytes", tarball.len());
645
646    // Read README if present
647    let readme = project_root.join("README.md");
648    let readme_content = if readme.exists() {
649        fs::read_to_string(&readme).ok()
650    } else {
651        None
652    };
653
654    // Build metadata
655    let metadata = PublishMetadata {
656        name: name.clone(),
657        version: version.clone(),
658        description: manifest.package.description.clone(),
659        repository: None, // Could add to manifest later
660        homepage: None,
661        license: None,
662        keywords: vec![],
663        entry_point: manifest.package.entry.clone(),
664        dependencies: manifest
665            .dependencies
666            .iter()
667            .map(|(k, v)| (k.clone(), v.to_string()))
668            .collect(),
669        readme: readme_content,
670    };
671
672    if dry_run {
673        println!("\n[dry-run] Would publish to {}", registry_url);
674        println!("[dry-run] Package validated successfully");
675        return Ok(());
676    }
677
678    // Upload to registry
679    println!("Uploading to {}...", registry_url);
680    let client = RegistryClient::new(registry_url, &token);
681    let result = client.publish(name, version, &tarball, &metadata)?;
682
683    println!(
684        "\nPublished {} v{} to {}",
685        result.package, result.version, registry_url
686    );
687    println!("  SHA256: {}", result.sha256);
688
689    Ok(())
690}
691
692fn cmd_login(
693    registry: Option<&str>,
694    token: Option<String>,
695) -> Result<(), Box<dyn std::error::Error>> {
696    let registry_url = registry.unwrap_or(RegistryClient::default_url());
697
698    // Get token from argument or stdin
699    let token = match token {
700        Some(t) => t,
701        None => {
702            println!("To get a token, visit: {}/auth/github", registry_url);
703            println!("Then generate an API token from your profile.");
704            println!();
705            print!("Enter token for {}: ", registry_url);
706            io::stdout().flush()?;
707
708            let mut line = String::new();
709            io::stdin().read_line(&mut line)?;
710            line.trim().to_string()
711        }
712    };
713
714    if token.is_empty() {
715        return Err("Token cannot be empty".into());
716    }
717
718    // Validate token with registry
719    println!("Validating token...");
720    let client = RegistryClient::new(registry_url, &token);
721    let user_info = client.validate_token()?;
722
723    // Save to credentials file
724    let mut creds = Credentials::load().unwrap_or_default();
725    creds.set_token(registry_url, &token);
726    creds.save()?;
727
728    println!("Logged in as {} to {}", user_info.login, registry_url);
729
730    Ok(())
731}
732
733fn cmd_logout(registry: Option<&str>) -> Result<(), Box<dyn std::error::Error>> {
734    let registry_url = registry.unwrap_or(RegistryClient::default_url());
735
736    let mut creds = Credentials::load().unwrap_or_default();
737
738    if creds.get_token(registry_url).is_none() {
739        println!("Not logged in to {}", registry_url);
740        return Ok(());
741    }
742
743    creds.remove_token(registry_url);
744    creds.save()?;
745
746    println!("Logged out from {}", registry_url);
747
748    Ok(())
749}