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
224    /// Check the project for errors without producing a binary.
225    ///
226    /// Parses and type-checks the LOGOS source without invoking the full
227    /// build pipeline. Useful for quick validation during development.
228    ///
229    /// # Example
230    ///
231    /// ```bash
232    /// largo check
233    /// ```
234    Check,
235
236    /// Publish the package to the LOGOS registry.
237    ///
238    /// Packages the project as a tarball and uploads it to the specified
239    /// registry. Requires authentication via `largo login`.
240    ///
241    /// # Pre-flight Checks
242    ///
243    /// Before publishing, the command verifies:
244    /// - The entry point exists
245    /// - No uncommitted git changes (unless `--allow-dirty`)
246    /// - Valid authentication token
247    ///
248    /// # Example
249    ///
250    /// ```bash
251    /// largo publish              # Publish to default registry
252    /// largo publish --dry-run    # Validate without uploading
253    /// ```
254    Publish {
255        /// Registry URL. Defaults to `registry.logicaffeine.com`.
256        #[arg(long)]
257        registry: Option<String>,
258
259        /// Perform all validation without actually uploading.
260        /// Useful for testing the publish process.
261        #[arg(long)]
262        dry_run: bool,
263
264        /// Allow publishing with uncommitted git changes.
265        /// By default, publishing requires a clean working directory.
266        #[arg(long)]
267        allow_dirty: bool,
268    },
269
270    /// Authenticate with the package registry.
271    ///
272    /// Stores an API token for the specified registry. The token is saved
273    /// in `~/.config/logos/credentials.toml` with restricted permissions.
274    ///
275    /// # Token Acquisition
276    ///
277    /// Tokens can be obtained from the registry's web interface:
278    /// 1. Visit `{registry}/auth/github` to authenticate
279    /// 2. Generate an API token from your profile
280    /// 3. Provide it via `--token` or interactive prompt
281    ///
282    /// # Example
283    ///
284    /// ```bash
285    /// largo login                       # Interactive prompt
286    /// largo login --token tok_xxxxx     # Non-interactive
287    /// ```
288    Login {
289        /// Registry URL. Defaults to `registry.logicaffeine.com`.
290        #[arg(long)]
291        registry: Option<String>,
292
293        /// API token. If omitted, prompts for input on stdin.
294        #[arg(long)]
295        token: Option<String>,
296    },
297
298    /// Remove stored credentials for a registry.
299    ///
300    /// Deletes the authentication token from the local credentials file.
301    ///
302    /// # Example
303    ///
304    /// ```bash
305    /// largo logout
306    /// ```
307    Logout {
308        /// Registry URL. Defaults to `registry.logicaffeine.com`.
309        #[arg(long)]
310        registry: Option<String>,
311    },
312}
313
314/// Parse CLI arguments and execute the corresponding command.
315///
316/// This is the main entry point for the `largo` CLI. It parses command-line
317/// arguments using [`clap`], then dispatches to the appropriate handler
318/// function based on the subcommand.
319///
320/// # Errors
321///
322/// Returns an error if:
323/// - The project structure is invalid (missing `Largo.toml`)
324/// - File system operations fail
325/// - Build or compilation fails
326/// - Registry operations fail (authentication, network, etc.)
327///
328/// # Example
329///
330/// ```no_run
331/// use logicaffeine_cli::cli::run_cli;
332///
333/// fn main() {
334///     if let Err(e) = run_cli() {
335///         eprintln!("Error: {}", e);
336///         std::process::exit(1);
337///     }
338/// }
339/// ```
340pub fn run_cli() -> Result<(), Box<dyn std::error::Error>> {
341    let cli = Cli::parse();
342
343    match cli.command {
344        Commands::New { name } => cmd_new(&name),
345        Commands::Init { name } => cmd_init(name.as_deref()),
346        Commands::Build { release, verify, license, lib, target } => cmd_build(release, verify, license, lib, target),
347        Commands::Run { release, interpret } if interpret => cmd_run_interpret(),
348        Commands::Run { release, .. } => cmd_run(release),
349        Commands::Check => cmd_check(),
350        Commands::Verify { license } => cmd_verify(license),
351        Commands::Publish { registry, dry_run, allow_dirty } => {
352            cmd_publish(registry.as_deref(), dry_run, allow_dirty)
353        }
354        Commands::Login { registry, token } => cmd_login(registry.as_deref(), token),
355        Commands::Logout { registry } => cmd_logout(registry.as_deref()),
356    }
357}
358
359fn cmd_new(name: &str) -> Result<(), Box<dyn std::error::Error>> {
360    let project_dir = PathBuf::from(name);
361
362    if project_dir.exists() {
363        return Err(format!("Directory '{}' already exists", project_dir.display()).into());
364    }
365
366    // Create project structure
367    fs::create_dir_all(&project_dir)?;
368    fs::create_dir_all(project_dir.join("src"))?;
369
370    // Write Largo.toml
371    let manifest = Manifest::new(name);
372    fs::write(project_dir.join("Largo.toml"), manifest.to_toml()?)?;
373
374    // Write src/main.lg
375    let main_lg = r#"# Main
376
377A simple LOGOS program.
378
379## Main
380
381Show "Hello, world!".
382"#;
383    fs::write(project_dir.join("src/main.lg"), main_lg)?;
384
385    // Write .gitignore
386    fs::write(project_dir.join(".gitignore"), "/target\n")?;
387
388    println!("Created LOGOS project '{}'", name);
389    println!("  cd {}", project_dir.display());
390    println!("  largo run");
391
392    Ok(())
393}
394
395fn cmd_init(name: Option<&str>) -> Result<(), Box<dyn std::error::Error>> {
396    let current_dir = env::current_dir()?;
397    let project_name = name
398        .map(String::from)
399        .or_else(|| {
400            current_dir
401                .file_name()
402                .and_then(|n| n.to_str())
403                .map(String::from)
404        })
405        .unwrap_or_else(|| "project".to_string());
406
407    if current_dir.join("Largo.toml").exists() {
408        return Err("Largo.toml already exists".into());
409    }
410
411    // Create src directory if needed
412    fs::create_dir_all(current_dir.join("src"))?;
413
414    // Write Largo.toml
415    let manifest = Manifest::new(&project_name);
416    fs::write(current_dir.join("Largo.toml"), manifest.to_toml()?)?;
417
418    // Write src/main.lg if it doesn't exist
419    let main_path = current_dir.join("src/main.lg");
420    if !main_path.exists() {
421        let main_lg = r#"# Main
422
423A simple LOGOS program.
424
425## Main
426
427Show "Hello, world!".
428"#;
429        fs::write(main_path, main_lg)?;
430    }
431
432    println!("Initialized LOGOS project '{}'", project_name);
433
434    Ok(())
435}
436
437fn cmd_build(
438    release: bool,
439    verify: bool,
440    license: Option<String>,
441    lib: bool,
442    target: Option<String>,
443) -> Result<(), Box<dyn std::error::Error>> {
444    let current_dir = env::current_dir()?;
445    let project_root =
446        find_project_root(&current_dir).ok_or("Not in a LOGOS project (Largo.toml not found)")?;
447
448    // Run verification if requested
449    if verify {
450        run_verification(&project_root, license.as_deref())?;
451    }
452
453    let config = BuildConfig {
454        project_dir: project_root,
455        release,
456        lib_mode: lib,
457        target,
458    };
459
460    let result = build::build(config)?;
461
462    let mode = if release { "release" } else { "debug" };
463    println!("Built {} [{}]", result.binary_path.display(), mode);
464
465    Ok(())
466}
467
468fn cmd_verify(license: Option<String>) -> Result<(), Box<dyn std::error::Error>> {
469    let current_dir = env::current_dir()?;
470    let project_root =
471        find_project_root(&current_dir).ok_or("Not in a LOGOS project (Largo.toml not found)")?;
472
473    run_verification(&project_root, license.as_deref())?;
474    println!("Verification passed");
475    Ok(())
476}
477
478#[cfg(feature = "verification")]
479fn run_verification(
480    project_root: &std::path::Path,
481    license: Option<&str>,
482) -> Result<(), Box<dyn std::error::Error>> {
483    use logicaffeine_verify::{LicenseValidator, Verifier};
484
485    // Get license key from argument or environment
486    let license_key = license
487        .map(String::from)
488        .or_else(|| env::var("LOGOS_LICENSE").ok());
489
490    let license_key = license_key.ok_or(
491        "Verification requires a license key.\n\
492         Use --license <key> or set LOGOS_LICENSE environment variable.\n\
493         Get a license at https://logicaffeine.com/pricing",
494    )?;
495
496    // Validate license
497    println!("Validating license...");
498    let validator = LicenseValidator::new();
499    let plan = validator.validate(&license_key)?;
500    println!("License valid ({})", plan);
501
502    // Load and parse the project
503    let manifest = Manifest::load(project_root)?;
504    let entry_path = project_root.join(&manifest.package.entry);
505    let source = fs::read_to_string(&entry_path)?;
506
507    // For now, just verify that Z3 works
508    // TODO: Implement full AST encoding in Phase 2
509    println!("Running Z3 verification...");
510    let verifier = Verifier::new();
511
512    // Basic smoke test - verify that true is valid
513    verifier.check_bool(true)?;
514
515    Ok(())
516}
517
518#[cfg(not(feature = "verification"))]
519fn run_verification(
520    _project_root: &std::path::Path,
521    _license: Option<&str>,
522) -> Result<(), Box<dyn std::error::Error>> {
523    Err("Verification requires the 'verification' feature.\n\
524         Rebuild with: cargo build --features verification"
525        .into())
526}
527
528fn cmd_run(release: bool) -> Result<(), Box<dyn std::error::Error>> {
529    let current_dir = env::current_dir()?;
530    let project_root =
531        find_project_root(&current_dir).ok_or("Not in a LOGOS project (Largo.toml not found)")?;
532
533    let config = BuildConfig {
534        project_dir: project_root,
535        release,
536        lib_mode: false,
537        target: None,
538    };
539
540    let result = build::build(config)?;
541    let exit_code = build::run(&result)?;
542
543    if exit_code != 0 {
544        std::process::exit(exit_code);
545    }
546
547    Ok(())
548}
549
550fn cmd_run_interpret() -> Result<(), Box<dyn std::error::Error>> {
551    let current_dir = env::current_dir()?;
552    let project_root =
553        find_project_root(&current_dir).ok_or("Not in a LOGOS project (Largo.toml not found)")?;
554
555    let manifest = Manifest::load(&project_root)?;
556    let entry_path = project_root.join(&manifest.package.entry);
557    let source = fs::read_to_string(&entry_path)?;
558
559    let result = futures::executor::block_on(logicaffeine_compile::interpret_for_ui(&source));
560
561    for line in &result.lines {
562        println!("{}", line);
563    }
564
565    if let Some(err) = result.error {
566        eprintln!("{}", err);
567        std::process::exit(1);
568    }
569
570    Ok(())
571}
572
573fn cmd_check() -> Result<(), Box<dyn std::error::Error>> {
574    let current_dir = env::current_dir()?;
575    let project_root =
576        find_project_root(&current_dir).ok_or("Not in a LOGOS project (Largo.toml not found)")?;
577
578    let manifest = Manifest::load(&project_root)?;
579    let entry_path = project_root.join(&manifest.package.entry);
580
581    // Just compile to Rust without building (discard output, only care about success)
582    let _ = compile_project(&entry_path)?;
583
584    println!("Check passed");
585    Ok(())
586}
587
588// ============================================================
589// Phase 39: Registry Commands
590// ============================================================
591
592fn cmd_publish(
593    registry: Option<&str>,
594    dry_run: bool,
595    allow_dirty: bool,
596) -> Result<(), Box<dyn std::error::Error>> {
597    let current_dir = env::current_dir()?;
598    let project_root =
599        find_project_root(&current_dir).ok_or("Not in a LOGOS project (Largo.toml not found)")?;
600
601    // Load manifest
602    let manifest = Manifest::load(&project_root)?;
603    let name = &manifest.package.name;
604    let version = &manifest.package.version;
605
606    println!("Packaging {} v{}", name, version);
607
608    // Determine registry URL
609    let registry_url = registry.unwrap_or(RegistryClient::default_url());
610
611    // Get authentication token
612    let token = get_token(registry_url).ok_or_else(|| {
613        format!(
614            "No authentication token found for {}.\n\
615             Run 'largo login' or set LOGOS_TOKEN environment variable.",
616            registry_url
617        )
618    })?;
619
620    // Verify the package
621    let entry_path = project_root.join(&manifest.package.entry);
622    if !entry_path.exists() {
623        return Err(format!(
624            "Entry point '{}' not found",
625            manifest.package.entry
626        ).into());
627    }
628
629    // Check for uncommitted changes
630    if !allow_dirty && is_git_dirty(&project_root) {
631        return Err(
632            "Working directory has uncommitted changes.\n\
633             Use --allow-dirty to publish anyway.".into()
634        );
635    }
636
637    // Create tarball
638    println!("Creating package tarball...");
639    let tarball = create_tarball(&project_root)?;
640    println!("  Package size: {} bytes", tarball.len());
641
642    // Read README if present
643    let readme = project_root.join("README.md");
644    let readme_content = if readme.exists() {
645        fs::read_to_string(&readme).ok()
646    } else {
647        None
648    };
649
650    // Build metadata
651    let metadata = PublishMetadata {
652        name: name.clone(),
653        version: version.clone(),
654        description: manifest.package.description.clone(),
655        repository: None, // Could add to manifest later
656        homepage: None,
657        license: None,
658        keywords: vec![],
659        entry_point: manifest.package.entry.clone(),
660        dependencies: manifest
661            .dependencies
662            .iter()
663            .map(|(k, v)| (k.clone(), v.to_string()))
664            .collect(),
665        readme: readme_content,
666    };
667
668    if dry_run {
669        println!("\n[dry-run] Would publish to {}", registry_url);
670        println!("[dry-run] Package validated successfully");
671        return Ok(());
672    }
673
674    // Upload to registry
675    println!("Uploading to {}...", registry_url);
676    let client = RegistryClient::new(registry_url, &token);
677    let result = client.publish(name, version, &tarball, &metadata)?;
678
679    println!(
680        "\nPublished {} v{} to {}",
681        result.package, result.version, registry_url
682    );
683    println!("  SHA256: {}", result.sha256);
684
685    Ok(())
686}
687
688fn cmd_login(
689    registry: Option<&str>,
690    token: Option<String>,
691) -> Result<(), Box<dyn std::error::Error>> {
692    let registry_url = registry.unwrap_or(RegistryClient::default_url());
693
694    // Get token from argument or stdin
695    let token = match token {
696        Some(t) => t,
697        None => {
698            println!("To get a token, visit: {}/auth/github", registry_url);
699            println!("Then generate an API token from your profile.");
700            println!();
701            print!("Enter token for {}: ", registry_url);
702            io::stdout().flush()?;
703
704            let mut line = String::new();
705            io::stdin().read_line(&mut line)?;
706            line.trim().to_string()
707        }
708    };
709
710    if token.is_empty() {
711        return Err("Token cannot be empty".into());
712    }
713
714    // Validate token with registry
715    println!("Validating token...");
716    let client = RegistryClient::new(registry_url, &token);
717    let user_info = client.validate_token()?;
718
719    // Save to credentials file
720    let mut creds = Credentials::load().unwrap_or_default();
721    creds.set_token(registry_url, &token);
722    creds.save()?;
723
724    println!("Logged in as {} to {}", user_info.login, registry_url);
725
726    Ok(())
727}
728
729fn cmd_logout(registry: Option<&str>) -> Result<(), Box<dyn std::error::Error>> {
730    let registry_url = registry.unwrap_or(RegistryClient::default_url());
731
732    let mut creds = Credentials::load().unwrap_or_default();
733
734    if creds.get_token(registry_url).is_none() {
735        println!("Not logged in to {}", registry_url);
736        return Ok(());
737    }
738
739    creds.remove_token(registry_url);
740    creds.save()?;
741
742    println!("Logged out from {}", registry_url);
743
744    Ok(())
745}