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 use std::io::Read;
158
159 let url = format!("{}/packages/publish", self.base_url);
160
161 // Create multipart form data
162 let boundary = format!("----LargoBoundary{}", rand::random::<u64>());
163
164 let metadata_json = serde_json::to_string(metadata)
165 .map_err(|e| RegistryError::InvalidPackage(e.to_string()))?;
166
167 let mut body = Vec::new();
168
169 // Add metadata field
170 body.extend_from_slice(format!(
171 "--{}\r\nContent-Disposition: form-data; name=\"metadata\"\r\n\r\n{}\r\n",
172 boundary, metadata_json
173 ).as_bytes());
174
175 // Add tarball field
176 body.extend_from_slice(format!(
177 "--{}\r\nContent-Disposition: form-data; name=\"tarball\"; filename=\"{}-{}.tar.gz\"\r\nContent-Type: application/gzip\r\n\r\n",
178 boundary, name, version
179 ).as_bytes());
180 body.extend_from_slice(tarball);
181 body.extend_from_slice(format!("\r\n--{}--\r\n", boundary).as_bytes());
182
183 let response = ureq::post(&url)
184 .set("Authorization", &format!("Bearer {}", self.token))
185 .set("Content-Type", &format!("multipart/form-data; boundary={}", boundary))
186 .send_bytes(&body)
187 .map_err(|e| match e {
188 ureq::Error::Status(401, _) => RegistryError::Unauthorized,
189 ureq::Error::Status(403, r) => {
190 let msg = r.into_string().unwrap_or_else(|_| "Forbidden".to_string());
191 RegistryError::Forbidden(msg)
192 }
193 ureq::Error::Status(409, _) => RegistryError::VersionExists {
194 name: name.to_string(),
195 version: version.to_string(),
196 },
197 ureq::Error::Status(413, _) => RegistryError::TooLarge,
198 ureq::Error::Status(code, r) => RegistryError::Server {
199 status: code,
200 message: r.into_string().unwrap_or_default(),
201 },
202 e => RegistryError::Network(e.to_string()),
203 })?;
204
205 let result: PublishResult = response.into_json()
206 .map_err(|e| RegistryError::Network(e.to_string()))?;
207
208 Ok(result)
209 }
210}
211
212/// Create a gzipped tarball from a LOGOS project.
213///
214/// Packages the project for upload to the registry. The tarball includes:
215/// - `Largo.toml` (required)
216/// - `src/` directory recursively (required)
217/// - `README.md` (if present)
218/// - `LICENSE` (if present)
219///
220/// Hidden files (starting with `.`) and the `target/` directory are excluded.
221/// Only `.lg`, `.md`, `.toml`, and `.json` files are included from `src/`.
222///
223/// # Arguments
224///
225/// * `project_dir` - Root directory of the LOGOS project
226///
227/// # Errors
228///
229/// Returns [`PackageError::MissingFile`] if `Largo.toml` or `src/` is missing.
230pub fn create_tarball(project_dir: &Path) -> Result<Vec<u8>, PackageError> {
231 use flate2::write::GzEncoder;
232 use flate2::Compression;
233 use tar::Builder;
234 use std::fs::File;
235 use std::io::Write;
236
237 let mut tarball = Vec::new();
238
239 {
240 let encoder = GzEncoder::new(&mut tarball, Compression::default());
241 let mut builder = Builder::new(encoder);
242
243 // Add Largo.toml
244 let manifest_path = project_dir.join("Largo.toml");
245 if !manifest_path.exists() {
246 return Err(PackageError::MissingFile("Largo.toml".to_string()));
247 }
248 add_file_to_tar(&mut builder, project_dir, "Largo.toml")?;
249
250 // Add src/ directory recursively
251 let src_dir = project_dir.join("src");
252 if !src_dir.exists() {
253 return Err(PackageError::MissingFile("src/".to_string()));
254 }
255 add_dir_recursive(&mut builder, project_dir, "src")?;
256
257 // Add README.md if it exists
258 if project_dir.join("README.md").exists() {
259 add_file_to_tar(&mut builder, project_dir, "README.md")?;
260 }
261
262 // Add LICENSE if it exists
263 if project_dir.join("LICENSE").exists() {
264 add_file_to_tar(&mut builder, project_dir, "LICENSE")?;
265 }
266
267 builder.finish()
268 .map_err(|e| PackageError::TarError(e.to_string()))?;
269 }
270
271 Ok(tarball)
272}
273
274fn add_file_to_tar<W: std::io::Write>(
275 builder: &mut tar::Builder<W>,
276 base_dir: &Path,
277 rel_path: &str,
278) -> Result<(), PackageError> {
279 let full_path = base_dir.join(rel_path);
280 let content = std::fs::read(&full_path)
281 .map_err(|e| PackageError::Io(format!("{}: {}", rel_path, e)))?;
282
283 let mut header = tar::Header::new_gnu();
284 header.set_path(rel_path)
285 .map_err(|e| PackageError::TarError(e.to_string()))?;
286 header.set_size(content.len() as u64);
287 header.set_mode(0o644);
288 header.set_mtime(0); // Reproducible builds
289 header.set_cksum();
290
291 builder.append(&header, content.as_slice())
292 .map_err(|e| PackageError::TarError(e.to_string()))?;
293
294 Ok(())
295}
296
297fn add_dir_recursive<W: std::io::Write>(
298 builder: &mut tar::Builder<W>,
299 base_dir: &Path,
300 rel_dir: &str,
301) -> Result<(), PackageError> {
302 let full_dir = base_dir.join(rel_dir);
303
304 for entry in std::fs::read_dir(&full_dir)
305 .map_err(|e| PackageError::Io(format!("{}: {}", rel_dir, e)))?
306 {
307 let entry = entry.map_err(|e| PackageError::Io(e.to_string()))?;
308 let path = entry.path();
309 let name = entry.file_name();
310 let rel_path = format!("{}/{}", rel_dir, name.to_string_lossy());
311
312 // Skip hidden files and target directory
313 let name_str = name.to_string_lossy();
314 if name_str.starts_with('.') || name_str == "target" {
315 continue;
316 }
317
318 if path.is_dir() {
319 add_dir_recursive(builder, base_dir, &rel_path)?;
320 } else if path.is_file() {
321 // Only include .lg, .md, and common config files
322 let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
323 if matches!(ext, "lg" | "md" | "toml" | "json") || name_str == "LICENSE" {
324 add_file_to_tar(builder, base_dir, &rel_path)?;
325 }
326 }
327 }
328
329 Ok(())
330}
331
332/// Check if the git working directory has uncommitted changes.
333///
334/// Runs `git status --porcelain` and returns `true` if there is any output,
335/// indicating uncommitted changes (modified, staged, or untracked files).
336///
337/// Returns `false` if:
338/// - The directory is not a git repository
339/// - Git is not available on the system
340/// - The working directory is clean
341///
342/// # Arguments
343///
344/// * `project_dir` - Directory to check (should contain `.git`)
345pub fn is_git_dirty(project_dir: &Path) -> bool {
346 use std::process::Command;
347
348 let output = Command::new("git")
349 .args(["status", "--porcelain"])
350 .current_dir(project_dir)
351 .output();
352
353 match output {
354 Ok(out) if out.status.success() => !out.stdout.is_empty(),
355 _ => false, // Not a git repo or git not available
356 }
357}
358
359// ============== Types ==============
360
361/// User information returned from the registry's `/auth/me` endpoint.
362///
363/// Contains details about the authenticated user, used to confirm
364/// successful login and display user information.
365#[derive(Debug, serde::Deserialize)]
366pub struct UserInfo {
367 /// Unique user identifier in the registry.
368 pub id: String,
369 /// GitHub username (used for login).
370 pub login: String,
371 /// Display name (may differ from login).
372 pub name: Option<String>,
373 /// Whether the user has registry admin privileges.
374 pub is_admin: bool,
375}
376
377/// Metadata submitted when publishing a package.
378///
379/// This information is stored in the registry index and displayed
380/// on the package's registry page.
381#[derive(Debug, serde::Serialize)]
382pub struct PublishMetadata {
383 /// Package name (must match `Largo.toml`).
384 pub name: String,
385 /// Semantic version string (e.g., "1.0.0").
386 pub version: String,
387 /// Short description of the package.
388 pub description: Option<String>,
389 /// URL to the source repository (e.g., GitHub).
390 pub repository: Option<String>,
391 /// URL to the project homepage or documentation.
392 pub homepage: Option<String>,
393 /// SPDX license identifier (e.g., "MIT", "Apache-2.0").
394 pub license: Option<String>,
395 /// Searchable keywords for discovery.
396 pub keywords: Vec<String>,
397 /// Relative path to the entry point file.
398 pub entry_point: String,
399 /// Map of dependency names to version requirements.
400 pub dependencies: std::collections::HashMap<String, String>,
401 /// Full README content (if `README.md` exists).
402 pub readme: Option<String>,
403}
404
405/// Response from a successful publish operation.
406///
407/// Returned by the registry after a package is successfully uploaded
408/// and indexed.
409#[derive(Debug, serde::Deserialize)]
410pub struct PublishResult {
411 /// Whether the publish succeeded.
412 pub success: bool,
413 /// The published package name.
414 pub package: String,
415 /// The published version.
416 pub version: String,
417 /// SHA-256 hash of the uploaded tarball.
418 pub sha256: String,
419 /// Size of the tarball in bytes.
420 pub size: u64,
421}
422
423// ============== Errors ==============
424
425/// Errors that can occur during registry API operations.
426///
427/// Each variant includes a user-friendly error message with guidance
428/// on how to resolve the issue.
429#[derive(Debug)]
430pub enum RegistryError {
431 /// No authentication token was provided or found.
432 NoToken,
433 /// The provided token is invalid or expired (HTTP 401).
434 Unauthorized,
435 /// The server rejected the request (HTTP 403).
436 Forbidden(String),
437 /// The package version already exists in the registry (HTTP 409).
438 VersionExists {
439 /// Package name.
440 name: String,
441 /// Version that already exists.
442 version: String,
443 },
444 /// The package tarball exceeds the size limit (HTTP 413).
445 TooLarge,
446 /// Network or connection error.
447 Network(String),
448 /// The server returned an unexpected error.
449 Server {
450 /// HTTP status code.
451 status: u16,
452 /// Error message from the server.
453 message: String,
454 },
455 /// The package metadata could not be serialized.
456 InvalidPackage(String),
457}
458
459impl std::fmt::Display for RegistryError {
460 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
461 match self {
462 Self::NoToken => write!(
463 f,
464 "No authentication token found.\n\
465 Run 'largo login' or set LOGOS_TOKEN environment variable."
466 ),
467 Self::Unauthorized => write!(
468 f,
469 "Authentication failed. Your token may be invalid or expired.\n\
470 Run 'largo login' to get a new token."
471 ),
472 Self::Forbidden(msg) => write!(f, "Access denied: {}", msg),
473 Self::VersionExists { name, version } => write!(
474 f,
475 "Version {} of package '{}' already exists.\n\
476 Update the version in Largo.toml and try again.",
477 version, name
478 ),
479 Self::TooLarge => write!(f, "Package too large. Maximum size is 10MB."),
480 Self::Network(e) => write!(f, "Network error: {}", e),
481 Self::Server { status, message } => {
482 write!(f, "Registry returned error {}: {}", status, message)
483 }
484 Self::InvalidPackage(e) => write!(f, "Invalid package: {}", e),
485 }
486 }
487}
488
489impl std::error::Error for RegistryError {}
490
491/// Errors that can occur when creating a package tarball.
492#[derive(Debug)]
493pub enum PackageError {
494 /// A required file is missing from the project.
495 MissingFile(String),
496 /// A file system operation failed.
497 Io(String),
498 /// The tar archive could not be created.
499 TarError(String),
500}
501
502impl std::fmt::Display for PackageError {
503 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
504 match self {
505 Self::MissingFile(name) => write!(f, "Missing required file: {}", name),
506 Self::Io(e) => write!(f, "I/O error: {}", e),
507 Self::TarError(e) => write!(f, "Failed to create tarball: {}", e),
508 }
509 }
510}
511
512impl std::error::Error for PackageError {}