use std::path::{Path, PathBuf};
pub fn is_valid_project_name(name: &str) -> bool {
let bytes = name.as_bytes();
match bytes.first() {
Some(first) if first.is_ascii_alphanumeric() || *first == b'_' => {}
_ => return false,
}
bytes.len() <= 64
&& bytes[1..]
.iter()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-'))
}
#[derive(Debug, Clone, PartialEq)]
pub struct ProjectSelection {
pub name: String,
pub path: String,
pub prompt: String,
pub was_explicit: bool,
}
#[derive(Debug, thiserror::Error)]
#[error("project {name} resolves to {path}, which is outside the project root {root}")]
pub struct ProjectEscapeError {
pub name: String,
pub path: String,
pub root: String,
}
fn prefix_match(message: &str) -> Option<(String, usize)> {
let mut matched = 0;
let mut end = 0;
for character in message.chars() {
if matched >= 64 || character.is_whitespace() || matches!(character, ':' | '/' | '\\') {
break;
}
matched += 1;
end += character.len_utf8();
}
if matched == 0 || !message[end..].starts_with(':') {
return None;
}
let after_colon = end + 1;
if message[after_colon..].starts_with("//") {
return None;
}
let whitespace = message[after_colon..]
.find(|character: char| !character.is_whitespace())
.unwrap_or(message.len() - after_colon);
Some((message[..end].to_owned(), after_colon + whitespace))
}
pub fn select_project(message: &str, root: &str, fallback_name: &str) -> ProjectSelection {
if let Some((candidate, prefix_len)) = prefix_match(message)
&& is_valid_project_name(&candidate)
{
return ProjectSelection {
path: join(root, &candidate),
name: candidate.clone(),
prompt: message[prefix_len..].trim().to_owned(),
was_explicit: true,
};
}
ProjectSelection {
name: fallback_name.to_owned(),
path: join(root, fallback_name),
prompt: message.trim().to_owned(),
was_explicit: false,
}
}
fn join(root: &str, name: &str) -> String {
Path::new(root).join(name).to_string_lossy().into_owned()
}
pub fn ensure_project_directory(
selection: &ProjectSelection,
root: &str,
) -> Result<(), ProjectEscapeError> {
std::fs::create_dir_all(&selection.path).map_err(|_| ProjectEscapeError {
name: selection.name.clone(),
path: selection.path.clone(),
root: root.to_owned(),
})?;
let real_root = std::fs::canonicalize(root).unwrap_or_else(|_| PathBuf::from(root));
let real_path =
std::fs::canonicalize(&selection.path).unwrap_or_else(|_| PathBuf::from(&selection.path));
let real_root = real_root.to_string_lossy().into_owned();
let real_path = real_path.to_string_lossy().into_owned();
if real_path != real_root && !real_path.starts_with(&format!("{real_root}/")) {
return Err(ProjectEscapeError {
name: selection.name.clone(),
path: real_path,
root: real_root,
});
}
Ok(())
}
#[cfg(test)]
mod tests;