use std::path::Component;
use crate::error::AppError;
use crate::utils::lock::LockRecover;
use crate::utils::time::now_secs;
use super::{RoutineResponse, RoutineStore};
#[derive(serde::Deserialize, schemars::JsonSchema, utoipa::ToSchema)]
pub struct MoveRoutineRequest {
#[serde(default)]
pub folder: Option<String>,
pub slug: String,
}
pub fn svc_move(
store: &RoutineStore,
id: &str,
req: MoveRoutineRequest,
) -> Result<RoutineResponse, AppError> {
let MoveRoutineRequest { folder, slug } = req;
let target = target_rel_path(folder.as_deref(), &slug)?;
let mut routine = store
.lock_recover()
.get(id)
.cloned()
.ok_or(AppError::NotFound)?;
let source = crate::routine_storage::routine_rel_dir(&routine);
if source == target {
return Ok(RoutineResponse::from_routine(routine));
}
let old_slug = crate::routine_storage::routine_slug(&routine);
let source_dir = crate::paths::routine_dir(&source);
let target_dir = crate::paths::routine_dir(&target);
if target_dir.exists() {
return Err(AppError::Conflict(format!(
"routine path \"{target}\" already exists"
)));
}
let parent = target_dir.parent().ok_or(AppError::Internal)?;
crate::utils::fs_perms::create_private_dir_all(parent).map_err(|_| AppError::Internal)?;
std::fs::rename(&source_dir, &target_dir).map_err(|_| AppError::Internal)?;
routine.updated_at = now_secs();
let new_slug = slug.trim().to_string();
store
.lock_recover()
.insert(routine.id.clone(), routine.clone());
migrate_workbenches(&old_slug, &new_slug);
if let Err(err) = crate::sync::routines::sync_routines_to_crontab(store) {
log_crontab_sync_failure(&err);
}
Ok(RoutineResponse::from_routine(routine))
}
#[cfg(not(test))]
fn log_crontab_sync_failure(err: &crate::sync::SyncError) {
log::warn!("crontab sync after routine move failed: {err}");
}
#[cfg(test)]
const fn log_crontab_sync_failure(_err: &crate::sync::SyncError) {}
fn target_rel_path(folder: Option<&str>, slug: &str) -> Result<String, AppError> {
let slug = clean_segment("slug", slug)?;
let folder = folder.map(str::trim).filter(|value| !value.is_empty());
match folder {
Some(folder) => {
validate_relative_path("folder", folder)?;
Ok(format!("{folder}/{slug}"))
}
None => Ok(slug),
}
}
fn clean_segment(name: &str, value: &str) -> Result<String, AppError> {
let trimmed = value.trim();
if trimmed.is_empty()
|| trimmed.contains('/')
|| trimmed.contains('\\')
|| trimmed == "."
|| trimmed == ".."
{
return Err(AppError::BadRequest(format!(
"{name} must be a single relative path segment"
)));
}
Ok(trimmed.to_string())
}
fn validate_relative_path(name: &str, value: &str) -> Result<(), AppError> {
if value.is_empty()
|| value.starts_with("./")
|| value.ends_with("/.")
|| value.contains("//")
|| value.contains("/./")
{
return Err(AppError::BadRequest(format!(
"{name} must not contain empty, current, or parent segments"
)));
}
let path = std::path::Path::new(value);
if path.is_absolute() {
return Err(AppError::BadRequest(format!("{name} must be relative")));
}
for component in path.components() {
match component {
Component::Normal(_) => {}
_ => {
return Err(AppError::BadRequest(format!(
"{name} must not contain empty, current, or parent segments"
)));
}
}
}
Ok(())
}
fn migrate_workbenches(old_slug: &str, new_slug: &str) {
if old_slug == new_slug {
return;
}
let Ok(entries) = std::fs::read_dir(crate::paths::workbenches_dir()) else {
return;
};
let prefix = format!("{old_slug}-");
for entry in entries.flatten() {
let name = entry.file_name().to_string_lossy().to_string();
let Some(suffix) = name.strip_prefix(&prefix) else {
continue;
};
let target = entry.path().with_file_name(format!("{new_slug}-{suffix}"));
if let Err(err) = std::fs::rename(entry.path(), target) {
log::warn!("routine move: failed to move workbench {name:?}: {err}");
}
}
}
#[cfg(test)]
#[path = "service_move_tests.rs"]
mod service_move_tests;