When new projects are added to the config file, `lore sync` doesn't pick them up because project discovery only happens during `lore init`. Previously, users had to use `--force` to overwrite their entire config. The new `--refresh` flag reads the existing config and updates the database to match, without modifying the config file itself. Features: - Validates GitLab authentication before processing - Registers new projects from config into the database - Detects orphan projects (in DB but removed from config) - Interactive mode: prompts to delete orphans (default: No) - Robot mode: returns JSON with orphan info, no prompts Usage: lore init --refresh # Interactive lore --robot init --refresh # JSON output Improved UX: When running `lore init` with an existing config and no flags, the error message now suggests using `--refresh` to register new projects or `--force` to overwrite the config file. Implementation: - Added RefreshOptions and RefreshResult types to init module - Added run_init_refresh() for core refresh logic - Added delete_orphan_projects() helper for orphan cleanup - Added handle_init_refresh() in main.rs for CLI handling - Added JSON output types for robot mode - Registered --refresh in autocorrect.rs command flags registry - --refresh conflicts with --force (mutually exclusive)
467 lines
14 KiB
Rust
467 lines
14 KiB
Rust
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<String>,
|
|
pub default_project: Option<String>,
|
|
}
|
|
|
|
pub struct InitOptions {
|
|
pub config_path: Option<String>,
|
|
pub force: bool,
|
|
pub non_interactive: bool,
|
|
}
|
|
|
|
pub struct InitResult {
|
|
pub config_path: String,
|
|
pub data_dir: String,
|
|
pub user: UserInfo,
|
|
pub projects: Vec<ProjectInfo>,
|
|
pub default_project: Option<String>,
|
|
}
|
|
|
|
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<String>,
|
|
pub non_interactive: bool,
|
|
}
|
|
|
|
pub struct RefreshResult {
|
|
pub user: UserInfo,
|
|
pub projects_registered: Vec<ProjectInfo>,
|
|
pub projects_failed: Vec<ProjectFailure>,
|
|
pub orphans_found: Vec<String>,
|
|
pub orphans_deleted: Vec<String>,
|
|
}
|
|
|
|
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<RefreshResult> {
|
|
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<ProjectFailure> = 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<String> = stmt
|
|
.query_map([], |row| row.get(0))?
|
|
.filter_map(|r| r.ok())
|
|
.collect();
|
|
|
|
let orphans: Vec<String> = 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<usize> {
|
|
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<InitResult> {
|
|
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<String>,
|
|
) -> Result<TokenSetResult> {
|
|
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<TokenShowResult> {
|
|
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*****");
|
|
}
|
|
}
|