use anyhow::{Context, Result};
use dialoguer::{Select, theme::ColorfulTheme};
use std::path::{Path, PathBuf};
use tokio::fs;
use crate::config::{Config, DotfileEntry, SourceType};
use crate::git;
use crate::symlinks;
pub fn get_dotme_dir() -> Result<PathBuf> {
let home = dirs::home_dir().context("Failed to get home directory")?;
Ok(home.join(".dotme"))
}
pub fn get_config_path() -> Result<PathBuf> {
Ok(get_dotme_dir()?.join("config.yml"))
}
pub fn get_git_dir() -> Result<PathBuf> {
Ok(get_dotme_dir()?.join("git"))
}
pub async fn init() -> Result<()> {
let dotme_dir = get_dotme_dir()?;
let config_path = get_config_path()?;
let git_dir = get_git_dir()?;
if config_path.exists() {
log::info!("DotMe is already initialized at {}", dotme_dir.display());
return Ok(());
}
log::info!("Initializing DotMe at {}", dotme_dir.display());
fs::create_dir_all(&dotme_dir)
.await
.context("Failed to create .dotme directory")?;
fs::create_dir_all(&git_dir)
.await
.context("Failed to create git directory")?;
let config = Config::default();
config.save(&config_path)?;
log::info!("DotMe initialized successfully!");
log::info!("Config file created at {}", config_path.display());
log::info!("Git repositories will be stored in {}", git_dir.display());
Ok(())
}
fn detect_source_type(source: &str) -> Result<SourceType> {
if source.starts_with("https://")
&& (source.ends_with(".git")
|| source.contains("github.com")
|| source.contains("gitlab.com"))
{
return Ok(SourceType::Git);
}
if source.starts_with("http://")
&& (source.ends_with(".git")
|| source.contains("github.com")
|| source.contains("gitlab.com"))
{
return Ok(SourceType::Git);
}
if source.starts_with("git@") || source.starts_with("ssh://git@") {
return Ok(SourceType::Git);
}
let path = Path::new(source);
let expanded_path = if path.is_absolute() {
path.to_path_buf()
} else {
std::env::current_dir()?.join(path)
};
if expanded_path.is_dir() {
if expanded_path.join(".git").exists() {
return Ok(SourceType::Git);
}
return Ok(SourceType::Directory);
}
if expanded_path.is_file() {
return Ok(SourceType::File);
}
anyhow::bail!(
"Could not determine source type for '{}'. Path does not exist or is not a valid git repository URL.",
source
)
}
pub async fn add(
source: &str,
target: Option<PathBuf>,
path: Option<PathBuf>,
folders: Option<Vec<String>>,
dry_run: bool,
) -> Result<()> {
let config_path = get_config_path()?;
if !config_path.exists() {
anyhow::bail!("DotMe is not initialized. Run 'dotme init' first.");
}
let mut config = Config::load(Some(config_path.clone()))?;
let source_type = detect_source_type(source)?;
log::info!("Detected source type: {}", source_type);
let base_path = if let Some(ref p) = path {
p.clone()
} else {
dirs::home_dir().context("Failed to get home directory")?
};
log::debug!("Symlinks will be created in: {}", base_path.display());
let source_path = if matches!(source_type, SourceType::Git)
&& !source.starts_with("http")
&& !source.starts_with("git@")
&& !source.starts_with("ssh://")
{
let p = Path::new(source);
if p.is_absolute() {
p.to_path_buf()
} else {
std::env::current_dir()?.join(p)
}
} else if !matches!(source_type, SourceType::Git) {
let p = Path::new(source);
if p.is_absolute() {
p.to_path_buf()
} else {
std::env::current_dir()?.join(p)
}
} else {
PathBuf::new()
};
if !source_path.as_os_str().is_empty() && source_path.is_dir() {
if base_path == source_path || base_path.starts_with(&source_path) {
anyhow::bail!(
"Cannot create symlinks in '{}' because it is the same as or inside the source directory '{}'. \
Use --path to specify a different location for symlinks.",
base_path.display(),
source_path.display()
);
}
}
let is_local_git = matches!(source_type, SourceType::Git)
&& !source.starts_with("https://")
&& !source.starts_with("http://")
&& !source.starts_with("git@")
&& !source.starts_with("ssh://");
let target = if let Some(t) = target {
t
} else {
if matches!(source_type, SourceType::Git) && !is_local_git {
let git_dir = get_git_dir()?;
let repo_name = source
.rsplit('/')
.next()
.unwrap_or("repo")
.trim_end_matches(".git");
git_dir.join(repo_name)
} else if is_local_git {
let source_path = Path::new(source);
if source_path.is_absolute() {
source_path.to_path_buf()
} else {
std::env::current_dir()?.join(source_path)
}
} else {
let source_path = Path::new(source);
let filename = source_path
.file_name()
.context("Failed to get filename from source")?;
base_path.join(filename)
}
};
if config.dotfiles.iter().any(|e| e.source == source) {
anyhow::bail!("Source '{}' is already being managed", source);
}
let selected_folders = if matches!(source_type, SourceType::Git) {
git::check_git_available().await?;
if !is_local_git {
git::clone(source, &target).await?;
} else {
log::info!("Using local git repository at: {}", target.display());
}
if path.is_some() {
if folders.is_some() {
log::warn!("--path flag overrides --folders; symlinking from repository root");
} else {
log::info!("Path specified, symlinking from repository root");
}
None
} else if folders.is_none() {
prompt_folder_selection(&target).await?
} else {
folders
}
} else {
folders
};
let entry = DotfileEntry {
source: source.to_string(),
target: target.clone(),
r#type: source_type,
path: Some(base_path.clone()),
folders: selected_folders,
};
config.dotfiles.push(entry.clone());
config.save(&config_path)?;
log::info!("Added '{}' to dotfiles management", source);
if dry_run {
println!("\n[DRY RUN] Symlinks that would be created:");
create_symlinks_for_entry(&entry, &base_path, dry_run).await?;
} else {
log::info!("Creating symlinks...");
create_symlinks_for_entry(&entry, &base_path, dry_run).await?;
}
Ok(())
}
pub async fn status() -> Result<()> {
let config_path = get_config_path()?;
if !config_path.exists() {
println!("DotMe is not initialized. Run 'dotme init' to set up dotfiles management.");
return Ok(());
}
let config = Config::load(Some(config_path))?;
if config.dotfiles.is_empty() {
println!("No dotfiles are currently being managed.");
println!("Use 'dotme add <source>' to add dotfiles.");
return Ok(());
}
println!("Managed Dotfiles:");
if let Some(updated) = &config.updated {
println!("Last updated: {}", format_timestamp(updated));
}
println!("─────────────────────────────────────────");
for entry in &config.dotfiles {
let status = if entry.target.exists() {
"✓ exists"
} else {
"✗ missing"
};
println!(" {} [{}]", status, entry.r#type);
println!(" Source: {}", entry.source);
if matches!(entry.r#type, SourceType::Git) {
println!(" Local: {}", entry.target.display());
if let Some(folders) = &entry.folders {
println!(" Folders: {}", folders.join(", "));
}
} else {
println!(" Target: {}", entry.target.display());
}
println!();
}
Ok(())
}
pub async fn update(dry_run: bool) -> Result<()> {
let config_path = get_config_path()?;
if !config_path.exists() {
anyhow::bail!("DotMe is not initialized. Run 'dotme init' first.");
}
let mut config = Config::load(Some(config_path.clone()))?;
if config.dotfiles.is_empty() {
log::info!("No dotfiles to update.");
return Ok(());
}
if dry_run {
println!("\n[DRY RUN] Update operation - showing what would be done:\n");
}
log::info!("Updating {} dotfile(s)...", config.dotfiles.len());
for entry in &config.dotfiles {
log::info!("Processing: {} [{}]", entry.source, entry.r#type);
let base_path = if let Some(ref p) = entry.path {
p.clone()
} else {
dirs::home_dir().context("Failed to get home directory")?
};
if matches!(entry.r#type, SourceType::Git) {
if !entry.target.exists() {
if dry_run {
println!("[DRY RUN] Would clone repository: {}", entry.source);
} else {
log::info!("Repository not found, cloning...");
git::clone(&entry.source, &entry.target).await?;
}
} else {
if dry_run {
println!("[DRY RUN] Would pull latest changes from: {}", entry.source);
} else {
git::pull(&entry.target).await?;
}
}
}
let removed_count = remove_symlinks_for_entry(entry, Some(&base_path), dry_run).await?;
if removed_count > 0 {
log::info!("Removing old symlinks");
}
log::info!("Creating new symlinks");
create_symlinks_for_entry(entry, &base_path, dry_run).await?;
}
if !dry_run {
config.update_timestamp();
config.save(&config_path)?;
} else {
println!("\n[DRY RUN] Would update timestamp in config");
}
log::info!("Update complete!");
Ok(())
}
async fn copy_file(source: &str, target: &Path) -> Result<()> {
let source_path = Path::new(source);
if !source_path.exists() {
anyhow::bail!("Source file '{}' does not exist", source);
}
if let Some(parent) = target.parent() {
fs::create_dir_all(parent).await?;
}
fs::copy(source_path, target).await.context(format!(
"Failed to copy {} to {}",
source,
target.display()
))?;
log::info!(" ✓ Copied to {}", target.display());
Ok(())
}
async fn copy_directory(source: &str, target: &Path) -> Result<()> {
let source_path = Path::new(source);
if !source_path.exists() {
anyhow::bail!("Source directory '{}' does not exist", source);
}
fs::create_dir_all(target).await?;
let mut entries = fs::read_dir(source_path).await?;
while let Some(entry) = entries.next_entry().await? {
let path = entry.path();
let file_name = path.file_name().unwrap();
let target_path = target.join(file_name);
if path.is_dir() {
Box::pin(copy_directory(path.to_str().unwrap(), &target_path)).await?;
} else {
fs::copy(&path, &target_path).await?;
}
}
log::info!(" ✓ Copied directory to {}", target.display());
Ok(())
}
pub async fn remove(source: Option<String>) -> Result<()> {
let config_path = get_config_path()?;
if !config_path.exists() {
anyhow::bail!("DotMe is not initialized. Run 'dotme init' first.");
}
let mut config = Config::load(Some(config_path.clone()))?;
if config.dotfiles.is_empty() {
log::info!("No dotfiles are currently being managed.");
return Ok(());
}
let entry_to_remove = if let Some(src) = source {
config
.dotfiles
.iter()
.find(|e| e.source == src)
.ok_or_else(|| anyhow::anyhow!("Source '{}' is not being managed", src))?
.clone()
} else {
let items: Vec<String> = config
.dotfiles
.iter()
.map(|e| format!("[{}] {}", e.r#type, e.source))
.collect();
if items.is_empty() {
log::info!("No dotfiles to remove.");
return Ok(());
}
let selection = Select::with_theme(&ColorfulTheme::default())
.with_prompt("Select dotfile to remove")
.items(&items)
.default(0)
.interact()?;
config.dotfiles[selection].clone()
};
log::info!("Removing '{}' from management", entry_to_remove.source);
log::info!("Removing associated symlinks...");
let removed_count = remove_symlinks_for_entry(&entry_to_remove, None, false).await?;
if removed_count > 0 {
log::info!("✓ Removed {} symlink(s)", removed_count);
} else {
log::info!("No symlinks to remove");
}
if matches!(entry_to_remove.r#type, SourceType::Git) {
let git_dir = config.paths.get_git_dir()?;
if entry_to_remove.target.starts_with(&git_dir) {
if entry_to_remove.target.exists() {
log::info!(
"Deleting git repository at: {}",
entry_to_remove.target.display()
);
fs::remove_dir_all(&entry_to_remove.target)
.await
.context("Failed to remove git repository directory")?;
log::info!("✓ Git repository deleted");
}
} else {
log::debug!(
"Skipping deletion of git repository at '{}' (not in git_dir: '{}')",
entry_to_remove.target.display(),
git_dir.display()
);
}
}
config
.dotfiles
.retain(|e| e.source != entry_to_remove.source);
config.save(&config_path)?;
log::info!(
"✓ Removed '{}' from dotfiles management",
entry_to_remove.source
);
Ok(())
}
async fn prompt_folder_selection(repo_path: &Path) -> Result<Option<Vec<String>>> {
use dialoguer::{MultiSelect, Select, theme::ColorfulTheme};
let indexing_options = vec![
"Root (map repository root to HOME)",
"Folders (select specific folders)",
];
println!("\nSelect indexing mode for git repository:");
let indexing_selection = Select::with_theme(&ColorfulTheme::default())
.with_prompt("Indexing mode")
.items(&indexing_options)
.default(0)
.interact()?;
if indexing_selection == 0 {
log::info!("Root indexing selected - mapping repository root to HOME directory");
return Ok(None);
}
log::info!("Folders indexing selected - prompting for folder selection");
let mut folders = Vec::new();
let mut entries = fs::read_dir(repo_path).await?;
while let Some(entry) = entries.next_entry().await? {
let path = entry.path();
if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
if !name.starts_with('.') && path.is_dir() {
folders.push(name.to_string());
}
}
}
if folders.is_empty() {
log::info!("No folders found in repository");
return Ok(None);
}
folders.sort();
println!("\nSelect folders to sync to your home directory:");
println!("(Use Space to select/deselect, Enter to confirm)");
let selections = MultiSelect::with_theme(&ColorfulTheme::default())
.with_prompt("Select folders")
.items(&folders)
.interact()?;
if selections.is_empty() {
log::info!("No folders selected, repository will be managed without folder filtering");
return Ok(None);
}
let selected_folders: Vec<String> = selections.iter().map(|&i| folders[i].clone()).collect();
log::info!("Selected folders: {}", selected_folders.join(", "));
Ok(Some(selected_folders))
}
async fn sync_git_folders(repo_path: &Path, folders: &[String]) -> Result<()> {
let home = dirs::home_dir().context("Failed to get home directory")?;
for folder in folders {
let source_folder = repo_path.join(folder);
if !source_folder.exists() {
log::warn!("Folder '{}' does not exist in repository, skipping", folder);
continue;
}
if !source_folder.is_dir() {
log::warn!("'{}' is not a directory, skipping", folder);
continue;
}
let target_folder = home.join(folder);
log::info!("Syncing folder '{}' to {}", folder, target_folder.display());
copy_directory(source_folder.to_str().unwrap(), &target_folder).await?;
}
Ok(())
}
fn format_timestamp(timestamp: &str) -> String {
use chrono::{DateTime, Local};
if let Ok(dt) = timestamp.parse::<DateTime<chrono::Utc>>() {
let local: DateTime<Local> = dt.into();
local.format("%Y-%m-%d %H:%M:%S").to_string()
} else {
timestamp.to_string()
}
}
async fn remove_symlinks_for_entry(
entry: &DotfileEntry,
_base_path: Option<&Path>,
dry_run: bool,
) -> Result<usize> {
use crate::symlinks::SymlinkState;
let state = SymlinkState::load().await?;
if state.symlinks.is_empty() {
return Ok(0);
}
let mut removed_count = 0;
let mut symlinks_to_remove = Vec::new();
let target_path = match entry.r#type {
SourceType::File => {
Path::new(&entry.source).to_path_buf()
}
SourceType::Directory => {
Path::new(&entry.source).to_path_buf()
}
SourceType::Git => {
entry.target.clone()
}
};
log::debug!("Looking for symlinks pointing to: {:?}", target_path);
for symlink_entry in &state.symlinks {
if symlink_entry.target.starts_with(&target_path) {
log::debug!(
"Found symlink to remove: {} -> {}",
symlink_entry.link.display(),
symlink_entry.target.display()
);
symlinks_to_remove.push(symlink_entry.link.clone());
}
}
for link in &symlinks_to_remove {
if dry_run {
println!("[DRY RUN] Would remove old symlink: {}", link.display());
removed_count += 1;
} else {
match symlinks::remove_symlink(link).await {
Ok(_) => {
removed_count += 1;
log::debug!(" ✓ Removed symlink: {}", link.display());
}
Err(e) => {
log::warn!(" ✗ Failed to remove symlink {}: {}", link.display(), e);
}
}
}
}
Ok(removed_count)
}
async fn create_symlinks_for_entry(
entry: &DotfileEntry,
base_path: &Path,
dry_run: bool,
) -> Result<()> {
match entry.r#type {
SourceType::File => {
let source_path = Path::new(&entry.source);
let filename = source_path.file_name().context("Failed to get filename")?;
let target_path = base_path.join(filename);
create_symlink_if_needed(&target_path, source_path, dry_run).await?;
}
SourceType::Directory => {
let source_path = Path::new(&entry.source);
let mut entries_list = fs::read_dir(source_path).await?;
while let Some(dir_entry) = entries_list.next_entry().await? {
let item_path = dir_entry.path();
let item_name = item_path.file_name().context("Failed to get item name")?;
if item_name == ".git" {
continue;
}
let target_path = base_path.join(item_name);
if item_path.is_dir() {
process_directory_for_symlinks(&item_path, &target_path, dry_run).await?;
} else {
create_symlink_if_needed(&target_path, &item_path, dry_run).await?;
}
}
}
SourceType::Git => {
if let Some(folders) = &entry.folders {
for folder in folders {
let source_folder = entry.target.join(folder);
if !source_folder.exists() {
log::warn!("Folder '{}' does not exist in repository, skipping", folder);
continue;
}
log::info!("Processing folder: {}", folder);
let mut entries_list = fs::read_dir(&source_folder).await?;
while let Some(dir_entry) = entries_list.next_entry().await? {
let item_path = dir_entry.path();
let item_name = item_path.file_name().context("Failed to get item name")?;
if item_name == ".git" {
continue;
}
let target_path = base_path.join(item_name);
if item_path.is_dir() {
process_directory_for_symlinks(&item_path, &target_path, dry_run)
.await?;
} else {
create_symlink_if_needed(&target_path, &item_path, dry_run).await?;
}
}
}
} else {
let mut entries_list = fs::read_dir(&entry.target).await?;
while let Some(dir_entry) = entries_list.next_entry().await? {
let item_path = dir_entry.path();
let item_name = item_path.file_name().context("Failed to get item name")?;
if item_name == ".git" {
continue;
}
let target_path = base_path.join(item_name);
if item_path.is_dir() {
process_directory_for_symlinks(&item_path, &target_path, dry_run).await?;
} else {
create_symlink_if_needed(&target_path, &item_path, dry_run).await?;
}
}
}
}
}
Ok(())
}
async fn process_directory_for_symlinks(
source_dir: &Path,
target_dir: &Path,
dry_run: bool,
) -> Result<()> {
log::debug!("Processing directory: {:?} -> {:?}", source_dir, target_dir);
if !source_dir.exists() {
log::warn!("Source directory does not exist: {:?}", source_dir);
return Ok(());
}
if target_dir.symlink_metadata().is_ok() {
if target_dir.is_dir() {
log::debug!("Target directory exists, processing contents recursively");
let mut entries = fs::read_dir(source_dir).await?;
while let Some(entry) = entries.next_entry().await? {
let source_path = entry.path();
let item_name = source_path.file_name().context("Failed to get item name")?;
if item_name == ".git" {
log::debug!("Skipping .git directory");
continue;
}
let target_path = target_dir.join(item_name);
if source_path.is_dir() {
Box::pin(process_directory_for_symlinks(
&source_path,
&target_path,
dry_run,
))
.await?;
} else {
create_symlink_if_needed(&target_path, &source_path, dry_run).await?;
}
}
} else {
log::debug!("Target exists as file/symlink, skipping: {:?}", target_dir);
if dry_run {
println!("[DRY RUN] Would skip (exists): {}", target_dir.display());
}
}
} else {
log::debug!("Target directory doesn't exist, creating symlink to entire directory");
if dry_run {
println!(
"[DRY RUN] Would create symlink: {} -> {}",
target_dir.display(),
source_dir.display()
);
} else {
symlinks::create_symlink(target_dir, source_dir).await?;
}
}
Ok(())
}
async fn create_symlink_if_needed(link: &Path, target: &Path, dry_run: bool) -> Result<()> {
if link.exists() || link.symlink_metadata().is_ok() {
log::debug!("Path already exists, skipping: {:?}", link);
if dry_run {
println!("[DRY RUN] Would skip (exists): {}", link.display());
}
return Ok(());
}
log::debug!("Creating symlink: {:?} -> {:?}", link, target);
if !target.exists() {
log::warn!("Source does not exist, cannot create symlink: {:?}", target);
if dry_run {
println!(
"[DRY RUN] Would skip (source missing): {} -> {}",
link.display(),
target.display()
);
}
return Ok(());
}
if dry_run {
println!(
"[DRY RUN] Would create symlink: {} -> {}",
link.display(),
target.display()
);
} else {
symlinks::create_symlink(link, target).await?;
}
Ok(())
}
pub async fn list() -> Result<()> {
log::info!("Loading symlink state...");
let symlinks = symlinks::list_symlinks().await?;
if symlinks.is_empty() {
println!("No symlinks are currently managed by DotMe.");
println!("Use 'dotme add <source>' to add dotfiles and create symlinks.");
return Ok(());
}
println!("Managed Symlinks:");
println!("─────────────────────────────────────────");
for (entry, status) in symlinks {
let status_str = match status {
Ok(true) => "✓ valid",
Ok(false) => "⚠ points to wrong target",
Err(_) => "✗ broken or missing",
};
println!(" {} {}", status_str, entry.link.display());
println!(" → {}", entry.target.display());
println!(" Created: {}", format_timestamp(&entry.created_at));
if let Some(verified) = &entry.last_verified {
println!(" Verified: {}", format_timestamp(verified));
}
println!();
}
Ok(())
}