fix: report what happened instead of losing it

This commit is contained in:
2026-09-09 11:59:58 +00:00
parent 75f293ce36
commit b4b6d5918f
7 changed files with 168 additions and 33 deletions

View File

@@ -1,4 +1,4 @@
use std::path::{Path, PathBuf};
use std::path::Path;
use anyhow::{Result, anyhow};
@@ -16,9 +16,20 @@ class Command(BaseCommand):
"#;
pub fn make_command(ctx: &Ctx, app: &PathBuf, name: &str) -> Result<()> {
pub fn make_command(ctx: &Ctx, app: &Path, name: &str) -> Result<()> {
let app_name = app.to_string_lossy();
let app_dir = Path::new(&app);
let app_dir = app;
// it becomes `<name>.py` under the app, and django imports it by this name,
// so anything that is not an identifier would land the file somewhere else
// or somewhere django will never look for it
if !is_module_name(name) {
return Err(anyhow!(
"{name:?} is not a usable command name; \
django imports it as a python module, so it can hold only \
letters, digits and underscores"
));
}
if !app_dir.is_dir() {
return Err(anyhow!("directory {app_name} does not exist"));
@@ -54,16 +65,30 @@ pub fn make_command(ctx: &Ctx, app: &PathBuf, name: &str) -> Result<()> {
Ok(())
}
fn is_module_name(name: &str) -> bool {
!name.is_empty()
&& !name.starts_with(|c: char| c.is_ascii_digit())
&& name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
}
pub fn bash(ctx: &Ctx) -> Result<()> {
Bash.in_service(&service(ctx)?).replace(ctx)
Bash.in_service(&service(ctx)?)
.quiet(ctx.quiet)
.replace(ctx)
}
pub fn run(ctx: &Ctx, rest: &[String]) -> Result<()> {
Words::new(rest).in_service(&service(ctx)?).replace(ctx)
Words::new(rest)
.in_service(&service(ctx)?)
.quiet(ctx.quiet)
.replace(ctx)
}
pub fn manage(ctx: &Ctx, rest: &[String]) -> Result<()> {
Manage::new(rest).in_service(&service(ctx)?).replace(ctx)
Manage::new(rest)
.in_service(&service(ctx)?)
.quiet(ctx.quiet)
.replace(ctx)
}
// shortcuts
@@ -85,3 +110,20 @@ pub fn shell(ctx: &Ctx) -> Result<()> {
fn service(ctx: &Ctx) -> Result<String> {
Project::resolve(ctx)?.django()
}
#[cfg(test)]
mod tests {
use super::is_module_name;
#[test]
fn a_command_name_has_to_be_a_python_module_name() {
for name in ["report", "send_mail", "_private", "sync2"] {
assert!(is_module_name(name), "should be usable: {name}");
}
// a path would put the file somewhere other than the app
for name in ["../../../etc/cron.d/x", "a/b", "with space", "dash-ed", ""] {
assert!(!is_module_name(name), "should be refused: {name}");
}
}
}

View File

@@ -28,7 +28,7 @@ pub fn add(ctx: &Ctx, paths: &[PathBuf], force: bool, store: Option<&Path>) -> R
let mut failed = 0;
for path in paths {
if let Err(e) = link_one(ctx, &repo, path, force, &report) {
note!(ctx, "error: {e:#}");
warning!("{e:#}");
failed += 1;
}
}
@@ -44,11 +44,11 @@ pub fn restore(ctx: &Ctx, paths: &[PathBuf], all: bool, store: Option<&Path>) ->
let repo = Repo::discover(ctx, store)?;
let report = Report::new(&repo);
let stored = match (all, paths) {
(true, []) => stored_paths(&repo, &repo.store)?,
(true, _) => bail!("--all restores everything, so it takes no paths"),
(false, []) => bail!("name a path to restore, or pass --all"),
(false, paths) => paths.iter().cloned().map(|p| (p, Stored::Linked)).collect(),
// clap requires one or the other and refuses both, so only the two real
// cases are left here
let stored = match all {
true => stored_paths(&repo, &repo.store)?,
false => paths.iter().cloned().map(|p| (p, Stored::Linked)).collect(),
};
// only a linked path can be moved back; the store can hold orphans too
@@ -88,7 +88,7 @@ pub fn restore(ctx: &Ctx, paths: &[PathBuf], all: bool, store: Option<&Path>) ->
let mut failed = 0;
for path in &linked {
if let Err(e) = restore_one(ctx, &repo, path, &report) {
note!(ctx, "error: {e:#}");
warning!("{e:#}");
failed += 1;
}
}