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