use std::fs; use std::io::{IsTerminal, Read}; use crate::core::config::{Config, MinimalConfig, MinimalGitLabConfig, ProjectConfig}; use crate::core::db::{create_connection, run_migrations}; use crate::core::error::{LoreError, Result}; use crate::core::paths::{ensure_config_permissions, get_config_path, get_data_dir}; use crate::gitlab::{GitLabClient, GitLabProject}; pub struct InitInputs { pub gitlab_url: String, pub token_env_var: String, pub project_paths: Vec, pub default_project: Option, } pub struct InitOptions { pub config_path: Option, pub force: bool, pub non_interactive: bool, } pub struct InitResult { pub config_path: String, pub data_dir: String, pub user: UserInfo, pub projects: Vec, pub default_project: Option, } pub struct UserInfo { pub username: String, pub name: String, } pub struct ProjectInfo { pub path: String, pub name: String, } // ── Refresh types ── pub struct RefreshOptions { pub config_path: Option, pub non_interactive: bool, } pub struct RefreshResult { pub user: UserInfo, pub projects_registered: Vec, pub projects_failed: Vec, pub orphans_found: Vec, pub orphans_deleted: Vec, } pub struct ProjectFailure { pub path: String, pub error: String, } /// Re-read existing config and register any new projects in the database. /// Does NOT modify the config file. pub async fn run_init_refresh(options: RefreshOptions) -> Result { let config_path = get_config_path(options.config_path.as_deref()); if !config_path.exists() { return Err(LoreError::ConfigNotFound { path: config_path.display().to_string(), }); } let config = Config::load(options.config_path.as_deref())?; let token = config.gitlab.resolve_token()?; let client = GitLabClient::new(&config.gitlab.base_url, &token, None); // Validate auth let gitlab_user = client.get_current_user().await.map_err(|e| { if matches!(e, LoreError::GitLabAuthFailed) { LoreError::Other(format!( "Authentication failed for {}", config.gitlab.base_url )) } else { e } })?; let user = UserInfo { username: gitlab_user.username, name: gitlab_user.name, }; // Validate each project let mut validated_projects: Vec<(ProjectInfo, GitLabProject)> = Vec::new(); let mut failed_projects: Vec = Vec::new(); for project_config in &config.projects { match client.get_project(&project_config.path).await { Ok(project) => { validated_projects.push(( ProjectInfo { path: project_config.path.clone(), name: project.name.clone().unwrap_or_else(|| { project_config .path .split('/') .next_back() .unwrap_or(&project_config.path) .to_string() }), }, project, )); } Err(e) => { let error_msg = if matches!(e, LoreError::GitLabNotFound { .. }) { "not found".to_string() } else { e.to_string() }; failed_projects.push(ProjectFailure { path: project_config.path.clone(), error: error_msg, }); } } } // Open database let data_dir = get_data_dir(); let db_path = data_dir.join("lore.db"); let conn = create_connection(&db_path)?; run_migrations(&conn)?; // Find orphans: projects in DB but not in config let config_paths: std::collections::HashSet<&str> = config.projects.iter().map(|p| p.path.as_str()).collect(); let mut stmt = conn.prepare("SELECT path_with_namespace FROM projects")?; let db_projects: Vec = stmt .query_map([], |row| row.get(0))? .filter_map(|r| r.ok()) .collect(); let orphans: Vec = db_projects .into_iter() .filter(|p| !config_paths.contains(p.as_str())) .collect(); // Upsert validated projects for (_, gitlab_project) in &validated_projects { conn.execute( "INSERT INTO projects (gitlab_project_id, path_with_namespace, default_branch, web_url) VALUES (?, ?, ?, ?) ON CONFLICT(gitlab_project_id) DO UPDATE SET path_with_namespace = excluded.path_with_namespace, default_branch = excluded.default_branch, web_url = excluded.web_url", ( gitlab_project.id, &gitlab_project.path_with_namespace, &gitlab_project.default_branch, &gitlab_project.web_url, ), )?; } Ok(RefreshResult { user, projects_registered: validated_projects.into_iter().map(|(p, _)| p).collect(), projects_failed: failed_projects, orphans_found: orphans, orphans_deleted: Vec::new(), // Caller handles deletion after user prompt }) } /// Delete orphan projects from the database. pub fn delete_orphan_projects(config_path: Option<&str>, orphans: &[String]) -> Result { let data_dir = get_data_dir(); let db_path = data_dir.join("lore.db"); let conn = create_connection(&db_path)?; let _ = config_path; // Reserved for future use let mut deleted = 0; for path in orphans { let rows = conn.execute("DELETE FROM projects WHERE path_with_namespace = ?", [path])?; deleted += rows; } Ok(deleted) } pub async fn run_init(inputs: InitInputs, options: InitOptions) -> Result { let config_path = get_config_path(options.config_path.as_deref()); let data_dir = get_data_dir(); if config_path.exists() && !options.force { if options.non_interactive { return Err(LoreError::Other(format!( "Config file exists at {}. Use --force to overwrite.", config_path.display() ))); } return Err(LoreError::Other( "User cancelled config overwrite.".to_string(), )); } if url::Url::parse(&inputs.gitlab_url).is_err() { return Err(LoreError::Other(format!( "Invalid GitLab URL: {}", inputs.gitlab_url ))); } let token = std::env::var(&inputs.token_env_var).map_err(|_| LoreError::TokenNotSet { env_var: inputs.token_env_var.clone(), })?; let client = GitLabClient::new(&inputs.gitlab_url, &token, None); let gitlab_user = client.get_current_user().await.map_err(|e| { if matches!(e, LoreError::GitLabAuthFailed) { LoreError::Other(format!("Authentication failed for {}", inputs.gitlab_url)) } else { e } })?; let user = UserInfo { username: gitlab_user.username, name: gitlab_user.name, }; let mut validated_projects: Vec<(ProjectInfo, GitLabProject)> = Vec::new(); for project_path in &inputs.project_paths { let project = client.get_project(project_path).await.map_err(|e| { if matches!(e, LoreError::GitLabNotFound { .. }) { LoreError::Other(format!("Project not found: {project_path}")) } else { e } })?; validated_projects.push(( ProjectInfo { path: project_path.clone(), name: project.name.clone().unwrap_or_else(|| { project_path .split('/') .next_back() .unwrap_or(project_path) .to_string() }), }, project, )); } // Validate default_project matches one of the configured project paths if let Some(ref dp) = inputs.default_project { let matched = inputs.project_paths.iter().any(|p| { p.eq_ignore_ascii_case(dp) || p.to_ascii_lowercase() .ends_with(&format!("/{}", dp.to_ascii_lowercase())) }); if !matched { return Err(LoreError::Other(format!( "defaultProject '{dp}' does not match any configured project path" ))); } } if let Some(parent) = config_path.parent() { fs::create_dir_all(parent)?; } let config = MinimalConfig { gitlab: MinimalGitLabConfig { base_url: inputs.gitlab_url, token_env_var: inputs.token_env_var, }, projects: inputs .project_paths .iter() .map(|p| ProjectConfig { path: p.clone() }) .collect(), default_project: inputs.default_project.clone(), }; let config_json = serde_json::to_string_pretty(&config)?; fs::write(&config_path, format!("{config_json}\n"))?; fs::create_dir_all(&data_dir)?; let db_path = data_dir.join("lore.db"); let conn = create_connection(&db_path)?; run_migrations(&conn)?; for (_, gitlab_project) in &validated_projects { conn.execute( "INSERT INTO projects (gitlab_project_id, path_with_namespace, default_branch, web_url) VALUES (?, ?, ?, ?) ON CONFLICT(gitlab_project_id) DO UPDATE SET path_with_namespace = excluded.path_with_namespace, default_branch = excluded.default_branch, web_url = excluded.web_url", ( gitlab_project.id, &gitlab_project.path_with_namespace, &gitlab_project.default_branch, &gitlab_project.web_url, ), )?; } Ok(InitResult { config_path: config_path.display().to_string(), data_dir: data_dir.display().to_string(), user, projects: validated_projects.into_iter().map(|(p, _)| p).collect(), default_project: inputs.default_project, }) } // ── token set / show ── pub struct TokenSetResult { pub username: String, pub config_path: String, } pub struct TokenShowResult { pub token: String, pub source: &'static str, } /// Read token from --token flag or stdin, validate against GitLab, store in config. pub async fn run_token_set( config_path_override: Option<&str>, token_arg: Option, ) -> Result { let config_path = get_config_path(config_path_override); if !config_path.exists() { return Err(LoreError::ConfigNotFound { path: config_path.display().to_string(), }); } // Resolve token value: flag > stdin > error let token = if let Some(t) = token_arg { t.trim().to_string() } else if !std::io::stdin().is_terminal() { let mut buf = String::new(); std::io::stdin() .read_to_string(&mut buf) .map_err(|e| LoreError::Other(format!("Failed to read token from stdin: {e}")))?; buf.trim().to_string() } else { return Err(LoreError::Other( "No token provided. Use --token or pipe to stdin.".to_string(), )); }; if token.is_empty() { return Err(LoreError::Other("Token cannot be empty.".to_string())); } // Load config to get the base URL for validation let config = Config::load(config_path_override)?; // Validate token against GitLab let client = GitLabClient::new(&config.gitlab.base_url, &token, None); let user = client.get_current_user().await.map_err(|e| { if matches!(e, LoreError::GitLabAuthFailed) { LoreError::Other("Token validation failed: authentication rejected by GitLab.".into()) } else { e } })?; // Read config as raw JSON, insert token, write back let content = fs::read_to_string(&config_path) .map_err(|e| LoreError::Other(format!("Failed to read config file: {e}")))?; let mut json: serde_json::Value = serde_json::from_str(&content).map_err(|e| LoreError::ConfigInvalid { details: format!("Invalid JSON in config file: {e}"), })?; json["gitlab"]["token"] = serde_json::Value::String(token); let output = serde_json::to_string_pretty(&json) .map_err(|e| LoreError::Other(format!("Failed to serialize config: {e}")))?; fs::write(&config_path, format!("{output}\n"))?; // Enforce permissions ensure_config_permissions(&config_path); Ok(TokenSetResult { username: user.username, config_path: config_path.display().to_string(), }) } /// Show the current token (masked or unmasked) and its source. pub fn run_token_show(config_path_override: Option<&str>, unmask: bool) -> Result { let config = Config::load(config_path_override)?; let source = config .gitlab .token_source() .ok_or_else(|| LoreError::TokenNotSet { env_var: config.gitlab.token_env_var.clone(), })?; let token = config.gitlab.resolve_token()?; let display_token = if unmask { token } else { mask_token(&token) }; Ok(TokenShowResult { token: display_token, source, }) } fn mask_token(token: &str) -> String { let len = token.len(); if len <= 8 { "*".repeat(len) } else { let visible = &token[..4]; format!("{visible}{}", "*".repeat(len - 4)) } } #[cfg(test)] mod tests { use super::*; #[test] fn mask_token_hides_short_tokens_completely() { assert_eq!(mask_token(""), ""); assert_eq!(mask_token("a"), "*"); assert_eq!(mask_token("abcd"), "****"); assert_eq!(mask_token("abcdefgh"), "********"); } #[test] fn mask_token_reveals_first_four_chars_for_long_tokens() { assert_eq!(mask_token("abcdefghi"), "abcd*****"); assert_eq!(mask_token("glpat-xyzABC123456"), "glpa**************"); } #[test] fn mask_token_boundary_at_nine_chars() { // 8 chars → fully masked, 9 chars → first 4 visible assert_eq!(mask_token("12345678"), "********"); assert_eq!(mask_token("123456789"), "1234*****"); } }