Skip to main content

logicaffeine_cli/project/
registry.rs

1//! Phase 39: Registry Client
2//!
3//! HTTP client for communicating with the LOGOS package registry.
4//!
5//! This module provides the [`RegistryClient`] for authenticated API calls to
6//! the package registry, along with supporting types for package metadata and
7//! error handling.
8//!
9//! # Architecture
10//!
11//! The registry client uses [`ureq`] for HTTP requests with Bearer token
12//! authentication. All requests are made over HTTPS to the configured registry
13//! URL (defaulting to `registry.logicaffeine.com`).
14//!
15//! # Example
16//!
17//! ```no_run
18//! use logicaffeine_cli::project::registry::{RegistryClient, PublishMetadata};
19//!
20//! let client = RegistryClient::new("https://registry.logicaffeine.com", "tok_xxx");
21//!
22//! // Validate authentication
23//! let user = client.validate_token()?;
24//! println!("Authenticated as: {}", user.login);
25//! # Ok::<(), Box<dyn std::error::Error>>(())
26//! ```
27
28use std::path::Path;
29
30const DEFAULT_REGISTRY_URL: &str = "https://registry.logicaffeine.com";
31
32/// HTTP client for the LOGOS package registry API.
33///
34/// Provides authenticated access to registry operations including:
35/// - Token validation
36/// - Package publishing
37///
38/// # Authentication
39///
40/// All API calls require a Bearer token, typically obtained via `largo login`.
41/// Tokens are validated against the registry's `/auth/me` endpoint.
42///
43/// # Example
44///
45/// ```no_run
46/// use logicaffeine_cli::project::registry::RegistryClient;
47///
48/// let client = RegistryClient::new(
49///     RegistryClient::default_url(),
50///     "tok_xxxxx"
51/// );
52///
53/// // Verify the token is valid
54/// match client.validate_token() {
55///     Ok(user) => println!("Logged in as {}", user.login),
56///     Err(e) => eprintln!("Auth failed: {}", e),
57/// }
58/// ```
59pub struct RegistryClient {
60    /// Base URL of the registry API (without trailing slash).
61    base_url: String,
62    /// Bearer token for authentication.
63    token: String,
64}
65
66impl RegistryClient {
67    /// Create a new registry client with the given URL and authentication token.
68    ///
69    /// # Arguments
70    ///
71    /// * `base_url` - The registry API base URL. Trailing slashes are stripped.
72    /// * `token` - Bearer token for authentication.
73    ///
74    /// # Example
75    ///
76    /// ```
77    /// use logicaffeine_cli::project::registry::RegistryClient;
78    ///
79    /// let client = RegistryClient::new(
80    ///     "https://registry.logicaffeine.com",
81    ///     "tok_xxxxx"
82    /// );
83    /// ```
84    pub fn new(base_url: &str, token: &str) -> Self {
85        Self {
86            base_url: base_url.trim_end_matches('/').to_string(),
87            token: token.to_string(),
88        }
89    }
90
91    /// Returns the default registry URL.
92    ///
93    /// Currently returns `https://registry.logicaffeine.com`.
94    pub fn default_url() -> &'static str {
95        DEFAULT_REGISTRY_URL
96    }
97
98    /// Validate the authentication token by querying the registry.
99    ///
100    /// Makes a request to `/auth/me` to verify the token is valid and
101    /// retrieve the associated user information.
102    ///
103    /// # Errors
104    ///
105    /// Returns [`RegistryError::Unauthorized`] if the token is invalid or expired.
106    /// Returns [`RegistryError::Network`] for connection failures.
107    pub fn validate_token(&self) -> Result<UserInfo, RegistryError> {
108        let url = format!("{}/auth/me", self.base_url);
109
110        let response = ureq::get(&url)
111            .set("Authorization", &format!("Bearer {}", self.token))
112            .call()
113            .map_err(|e| match e {
114                ureq::Error::Status(401, _) => RegistryError::Unauthorized,
115                ureq::Error::Status(403, r) => {
116                    let msg = r.into_string().unwrap_or_default();
117                    RegistryError::Forbidden(msg)
118                }
119                ureq::Error::Status(code, r) => RegistryError::Server {
120                    status: code,
121                    message: r.into_string().unwrap_or_default(),
122                },
123                e => RegistryError::Network(e.to_string()),
124            })?;
125
126        let user: UserInfo = response.into_json()
127            .map_err(|e| RegistryError::Network(e.to_string()))?;
128
129        Ok(user)
130    }
131
132    /// Publish a package to the registry.
133    ///
134    /// Uploads a package tarball with metadata to the registry's publish endpoint.
135    /// The request is sent as multipart form data.
136    ///
137    /// # Arguments
138    ///
139    /// * `name` - Package name (must match manifest)
140    /// * `version` - Semantic version string
141    /// * `tarball` - Gzipped tar archive of the package
142    /// * `metadata` - Package metadata for the registry index
143    ///
144    /// # Errors
145    ///
146    /// - [`RegistryError::Unauthorized`] - Invalid or missing token
147    /// - [`RegistryError::VersionExists`] - This version already published
148    /// - [`RegistryError::TooLarge`] - Package exceeds 10MB limit
149    /// - [`RegistryError::InvalidPackage`] - Metadata serialization failed
150    pub fn publish(
151        &self,
152        name: &str,
153        version: &str,
154        tarball: &[u8],
155        metadata: &PublishMetadata,
156    ) -> Result<PublishResult, RegistryError> {
157        let url = format!("{}/packages/publish", self.base_url);
158
159        // Create multipart form data
160        let boundary = format!("----LargoBoundary{}", rand::random::<u64>());
161
162        let metadata_json = serde_json::to_string(metadata)
163            .map_err(|e| RegistryError::InvalidPackage(e.to_string()))?;
164
165        let mut body = Vec::new();
166
167        // Add metadata field
168        body.extend_from_slice(format!(
169            "--{}\r\nContent-Disposition: form-data; name=\"metadata\"\r\n\r\n{}\r\n",
170            boundary, metadata_json
171        ).as_bytes());
172
173        // Add tarball field
174        body.extend_from_slice(format!(
175            "--{}\r\nContent-Disposition: form-data; name=\"tarball\"; filename=\"{}-{}.tar.gz\"\r\nContent-Type: application/gzip\r\n\r\n",
176            boundary, name, version
177        ).as_bytes());
178        body.extend_from_slice(tarball);
179        body.extend_from_slice(format!("\r\n--{}--\r\n", boundary).as_bytes());
180
181        let response = ureq::post(&url)
182            .set("Authorization", &format!("Bearer {}", self.token))
183            .set("Content-Type", &format!("multipart/form-data; boundary={}", boundary))
184            .send_bytes(&body)
185            .map_err(|e| match e {
186                ureq::Error::Status(401, _) => RegistryError::Unauthorized,
187                ureq::Error::Status(403, r) => {
188                    let msg = r.into_string().unwrap_or_else(|_| "Forbidden".to_string());
189                    RegistryError::Forbidden(msg)
190                }
191                ureq::Error::Status(409, _) => RegistryError::VersionExists {
192                    name: name.to_string(),
193                    version: version.to_string(),
194                },
195                ureq::Error::Status(413, _) => RegistryError::TooLarge,
196                ureq::Error::Status(code, r) => RegistryError::Server {
197                    status: code,
198                    message: r.into_string().unwrap_or_default(),
199                },
200                e => RegistryError::Network(e.to_string()),
201            })?;
202
203        let result: PublishResult = response.into_json()
204            .map_err(|e| RegistryError::Network(e.to_string()))?;
205
206        Ok(result)
207    }
208}
209
210/// Create a gzipped tarball from a LOGOS project.
211///
212/// Packages the project for upload to the registry. The tarball includes:
213/// - `Largo.toml` (required)
214/// - `src/` directory recursively (required)
215/// - `README.md` (if present)
216/// - `LICENSE` (if present)
217///
218/// Hidden files (starting with `.`) and the `target/` directory are excluded.
219/// Only `.lg`, `.md`, `.toml`, and `.json` files are included from `src/`.
220///
221/// # Arguments
222///
223/// * `project_dir` - Root directory of the LOGOS project
224///
225/// # Errors
226///
227/// Returns [`PackageError::MissingFile`] if `Largo.toml` or `src/` is missing.
228pub fn create_tarball(project_dir: &Path) -> Result<Vec<u8>, PackageError> {
229    use flate2::write::GzEncoder;
230    use flate2::Compression;
231    use tar::Builder;
232
233    let mut tarball = Vec::new();
234
235    {
236        let encoder = GzEncoder::new(&mut tarball, Compression::default());
237        let mut builder = Builder::new(encoder);
238
239        // Add Largo.toml
240        let manifest_path = project_dir.join("Largo.toml");
241        if !manifest_path.exists() {
242            return Err(PackageError::MissingFile("Largo.toml".to_string()));
243        }
244        add_file_to_tar(&mut builder, project_dir, "Largo.toml")?;
245
246        // Add src/ directory recursively
247        let src_dir = project_dir.join("src");
248        if !src_dir.exists() {
249            return Err(PackageError::MissingFile("src/".to_string()));
250        }
251        add_dir_recursive(&mut builder, project_dir, "src")?;
252
253        // Add README.md if it exists
254        if project_dir.join("README.md").exists() {
255            add_file_to_tar(&mut builder, project_dir, "README.md")?;
256        }
257
258        // Add LICENSE if it exists
259        if project_dir.join("LICENSE").exists() {
260            add_file_to_tar(&mut builder, project_dir, "LICENSE")?;
261        }
262
263        builder.finish()
264            .map_err(|e| PackageError::TarError(e.to_string()))?;
265    }
266
267    Ok(tarball)
268}
269
270fn add_file_to_tar<W: std::io::Write>(
271    builder: &mut tar::Builder<W>,
272    base_dir: &Path,
273    rel_path: &str,
274) -> Result<(), PackageError> {
275    let full_path = base_dir.join(rel_path);
276    let content = std::fs::read(&full_path)
277        .map_err(|e| PackageError::Io(format!("{}: {}", rel_path, e)))?;
278
279    let mut header = tar::Header::new_gnu();
280    header.set_path(rel_path)
281        .map_err(|e| PackageError::TarError(e.to_string()))?;
282    header.set_size(content.len() as u64);
283    header.set_mode(0o644);
284    header.set_mtime(0); // Reproducible builds
285    header.set_cksum();
286
287    builder.append(&header, content.as_slice())
288        .map_err(|e| PackageError::TarError(e.to_string()))?;
289
290    Ok(())
291}
292
293fn add_dir_recursive<W: std::io::Write>(
294    builder: &mut tar::Builder<W>,
295    base_dir: &Path,
296    rel_dir: &str,
297) -> Result<(), PackageError> {
298    let full_dir = base_dir.join(rel_dir);
299
300    for entry in std::fs::read_dir(&full_dir)
301        .map_err(|e| PackageError::Io(format!("{}: {}", rel_dir, e)))?
302    {
303        let entry = entry.map_err(|e| PackageError::Io(e.to_string()))?;
304        let path = entry.path();
305        let name = entry.file_name();
306        let rel_path = format!("{}/{}", rel_dir, name.to_string_lossy());
307
308        // Skip hidden files and target directory
309        let name_str = name.to_string_lossy();
310        if name_str.starts_with('.') || name_str == "target" {
311            continue;
312        }
313
314        if path.is_dir() {
315            add_dir_recursive(builder, base_dir, &rel_path)?;
316        } else if path.is_file() {
317            // Only include .lg, .md, and common config files
318            let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
319            if matches!(ext, "lg" | "md" | "toml" | "json") || name_str == "LICENSE" {
320                add_file_to_tar(builder, base_dir, &rel_path)?;
321            }
322        }
323    }
324
325    Ok(())
326}
327
328/// Check if the git working directory has uncommitted changes.
329///
330/// Runs `git status --porcelain` and returns `true` if there is any output,
331/// indicating uncommitted changes (modified, staged, or untracked files).
332///
333/// Returns `false` if:
334/// - The directory is not a git repository
335/// - Git is not available on the system
336/// - The working directory is clean
337///
338/// # Arguments
339///
340/// * `project_dir` - Directory to check (should contain `.git`)
341pub fn is_git_dirty(project_dir: &Path) -> bool {
342    use std::process::Command;
343
344    let output = Command::new("git")
345        .args(["status", "--porcelain"])
346        .current_dir(project_dir)
347        .output();
348
349    match output {
350        Ok(out) if out.status.success() => !out.stdout.is_empty(),
351        _ => false, // Not a git repo or git not available
352    }
353}
354
355// ============== Types ==============
356
357/// User information returned from the registry's `/auth/me` endpoint.
358///
359/// Contains details about the authenticated user, used to confirm
360/// successful login and display user information.
361#[derive(Debug, serde::Deserialize)]
362pub struct UserInfo {
363    /// Unique user identifier in the registry.
364    pub id: String,
365    /// GitHub username (used for login).
366    pub login: String,
367    /// Display name (may differ from login).
368    pub name: Option<String>,
369    /// Whether the user has registry admin privileges.
370    pub is_admin: bool,
371}
372
373/// Metadata submitted when publishing a package.
374///
375/// This information is stored in the registry index and displayed
376/// on the package's registry page.
377#[derive(Debug, serde::Serialize)]
378pub struct PublishMetadata {
379    /// Package name (must match `Largo.toml`).
380    pub name: String,
381    /// Semantic version string (e.g., "1.0.0").
382    pub version: String,
383    /// Short description of the package.
384    pub description: Option<String>,
385    /// URL to the source repository (e.g., GitHub).
386    pub repository: Option<String>,
387    /// URL to the project homepage or documentation.
388    pub homepage: Option<String>,
389    /// SPDX license identifier (e.g., "MIT", "Apache-2.0").
390    pub license: Option<String>,
391    /// Searchable keywords for discovery.
392    pub keywords: Vec<String>,
393    /// Relative path to the entry point file.
394    pub entry_point: String,
395    /// Map of dependency names to version requirements.
396    pub dependencies: std::collections::HashMap<String, String>,
397    /// Full README content (if `README.md` exists).
398    pub readme: Option<String>,
399}
400
401/// Response from a successful publish operation.
402///
403/// Returned by the registry after a package is successfully uploaded
404/// and indexed.
405#[derive(Debug, serde::Deserialize)]
406pub struct PublishResult {
407    /// Whether the publish succeeded.
408    pub success: bool,
409    /// The published package name.
410    pub package: String,
411    /// The published version.
412    pub version: String,
413    /// SHA-256 hash of the uploaded tarball.
414    pub sha256: String,
415    /// Size of the tarball in bytes.
416    pub size: u64,
417}
418
419// ============== Errors ==============
420
421/// Errors that can occur during registry API operations.
422///
423/// Each variant includes a user-friendly error message with guidance
424/// on how to resolve the issue.
425#[derive(Debug)]
426pub enum RegistryError {
427    /// No authentication token was provided or found.
428    NoToken,
429    /// The provided token is invalid or expired (HTTP 401).
430    Unauthorized,
431    /// The server rejected the request (HTTP 403).
432    Forbidden(String),
433    /// The package version already exists in the registry (HTTP 409).
434    VersionExists {
435        /// Package name.
436        name: String,
437        /// Version that already exists.
438        version: String,
439    },
440    /// The package tarball exceeds the size limit (HTTP 413).
441    TooLarge,
442    /// Network or connection error.
443    Network(String),
444    /// The server returned an unexpected error.
445    Server {
446        /// HTTP status code.
447        status: u16,
448        /// Error message from the server.
449        message: String,
450    },
451    /// The package metadata could not be serialized.
452    InvalidPackage(String),
453}
454
455impl std::fmt::Display for RegistryError {
456    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
457        match self {
458            Self::NoToken => write!(
459                f,
460                "No authentication token found.\n\
461                 Run 'largo login' or set LOGOS_TOKEN environment variable."
462            ),
463            Self::Unauthorized => write!(
464                f,
465                "Authentication failed. Your token may be invalid or expired.\n\
466                 Run 'largo login' to get a new token."
467            ),
468            Self::Forbidden(msg) => write!(f, "Access denied: {}", msg),
469            Self::VersionExists { name, version } => write!(
470                f,
471                "Version {} of package '{}' already exists.\n\
472                 Update the version in Largo.toml and try again.",
473                version, name
474            ),
475            Self::TooLarge => write!(f, "Package too large. Maximum size is 10MB."),
476            Self::Network(e) => write!(f, "Network error: {}", e),
477            Self::Server { status, message } => {
478                write!(f, "Registry returned error {}: {}", status, message)
479            }
480            Self::InvalidPackage(e) => write!(f, "Invalid package: {}", e),
481        }
482    }
483}
484
485impl std::error::Error for RegistryError {}
486
487/// Errors that can occur when creating a package tarball.
488#[derive(Debug)]
489pub enum PackageError {
490    /// A required file is missing from the project.
491    MissingFile(String),
492    /// A file system operation failed.
493    Io(String),
494    /// The tar archive could not be created.
495    TarError(String),
496}
497
498impl std::fmt::Display for PackageError {
499    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
500        match self {
501            Self::MissingFile(name) => write!(f, "Missing required file: {}", name),
502            Self::Io(e) => write!(f, "I/O error: {}", e),
503            Self::TarError(e) => write!(f, "Failed to create tarball: {}", e),
504        }
505    }
506}
507
508impl std::error::Error for PackageError {}