use std::path::{Component, Path, PathBuf};
use crate::error::AppError;
use super::models::ContentItem;
pub fn normalize_repo_path(path: &str) -> String {
let normalized = path.trim();
if matches!(normalized, "" | "." | "/") {
String::new()
} else {
normalized.trim_matches('/').to_string()
}
}
pub fn quote_repo_path(path: &str) -> String {
normalize_repo_path(path)
.split('/')
.filter(|segment| !segment.is_empty())
.map(urlencoding::encode)
.map(|segment| segment.into_owned())
.collect::<Vec<_>>()
.join("/")
}
pub fn join_proxy_url(proxy_base: &str, target_url: &str) -> String {
format!("{}/{}", proxy_base.trim_end_matches('/'), target_url)
}
pub fn redact_url_for_display(url: &str) -> String {
let Some(scheme_separator) = url.find("://") else {
return url.to_string();
};
let scheme_end = scheme_separator + 3;
let authority_end = url[scheme_end..]
.find(['/', '?', '#'])
.map(|index| scheme_end + index)
.unwrap_or(url.len());
let authority = &url[scheme_end..authority_end];
let Some(user_info_end) = authority.find('@') else {
return url.to_string();
};
format!(
"{}***@{}",
&url[..scheme_end],
&url[scheme_end + user_info_end + 1..]
)
}
pub fn build_contents_api_url(
api_base: &str,
repo: &str,
remote_path: &str,
git_ref: Option<&str>,
) -> String {
let quoted_path = quote_repo_path(remote_path);
let mut url = format!("{}/repos/{}/contents", api_base.trim_end_matches('/'), repo);
if !quoted_path.is_empty() {
url.push('/');
url.push_str("ed_path);
}
if let Some(git_ref) = git_ref.filter(|value| !value.trim().is_empty()) {
let separator = if url.contains('?') { '&' } else { '?' };
url.push(separator);
url.push_str("ref=");
url.push_str(&urlencoding::encode(git_ref));
}
url
}
pub fn format_remote_path(remote_path: &str) -> String {
let normalized = normalize_repo_path(remote_path);
if normalized.is_empty() {
"/".to_string()
} else {
normalized
}
}
pub fn relative_item_path(root_remote_path: &str, item_path: &str) -> String {
let normalized_root = normalize_repo_path(root_remote_path);
if normalized_root.is_empty() {
return item_path.to_string();
}
let prefix = format!("{}/", normalized_root);
item_path
.strip_prefix(&prefix)
.unwrap_or(item_path)
.to_string()
}
pub(super) fn safe_directory_relative_item_path(
root_remote_path: &str,
item_path: &str,
) -> Result<String, AppError> {
let relative_path = checked_relative_item_path(root_remote_path, item_path)?;
validate_relative_output_path(&relative_path)?;
Ok(relative_path)
}
fn checked_relative_item_path(root_remote_path: &str, item_path: &str) -> Result<String, AppError> {
let normalized_root = normalize_repo_path(root_remote_path);
if normalized_root.is_empty() {
return Ok(item_path.to_string());
}
let prefix = format!("{}/", normalized_root);
item_path
.strip_prefix(&prefix)
.map(ToOwned::to_owned)
.ok_or_else(|| unsafe_directory_metadata_path(item_path))
}
fn validate_relative_output_path(relative_path: &str) -> Result<(), AppError> {
if relative_path.is_empty() || relative_path.contains('\\') {
return Err(unsafe_directory_metadata_path(relative_path));
}
let path = Path::new(relative_path);
if path.is_absolute() {
return Err(unsafe_directory_metadata_path(relative_path));
}
let mut has_component = false;
for component in path.components() {
match component {
Component::Normal(_) => has_component = true,
Component::CurDir
| Component::ParentDir
| Component::RootDir
| Component::Prefix(_) => {
return Err(unsafe_directory_metadata_path(relative_path));
}
}
}
if has_component {
Ok(())
} else {
Err(unsafe_directory_metadata_path(relative_path))
}
}
fn unsafe_directory_metadata_path(path: &str) -> AppError {
AppError::InvalidPath(format!("unsafe directory metadata path: {}", path))
}
pub(super) fn choose_file_target(
local_target: &Path,
remote_path: &str,
item: &ContentItem,
) -> PathBuf {
let filename = item
.name
.clone()
.unwrap_or_else(|| file_name_from_remote_path(remote_path));
if local_target.exists() && local_target.is_dir() {
local_target.join(filename)
} else {
local_target.to_path_buf()
}
}
pub fn choose_directory_target(local_target: &Path, remote_path: &str) -> PathBuf {
let normalized_path = normalize_repo_path(remote_path);
let directory_name = Path::new(&normalized_path)
.file_name()
.and_then(|value| value.to_str())
.unwrap_or("");
if directory_name.is_empty() {
return local_target.to_path_buf();
}
if local_target
.file_name()
.and_then(|value| value.to_str())
.is_some_and(|name| name == directory_name)
{
local_target.to_path_buf()
} else {
local_target.join(directory_name)
}
}
pub(super) fn file_name_from_remote_path(remote_path: &str) -> String {
Path::new(remote_path)
.file_name()
.and_then(|value| value.to_str())
.unwrap_or("downloaded-file")
.to_string()
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use super::*;
#[test]
fn directory_target_reuses_existing_suffix() {
let local = PathBuf::from("/tmp/src");
assert_eq!(choose_directory_target(&local, "src"), local);
}
#[test]
fn directory_target_appends_directory_name() {
let local = PathBuf::from("/tmp/downloads");
assert_eq!(
choose_directory_target(&local, "src"),
PathBuf::from("/tmp/downloads/src")
);
}
#[test]
fn relative_item_path_strips_root_prefix() {
assert_eq!(
relative_item_path("src", "src/nested/lib.rs"),
"nested/lib.rs".to_string()
);
}
#[test]
fn safe_directory_relative_item_path_accepts_nested_paths() {
assert_eq!(
safe_directory_relative_item_path("src", "src/nested/lib.rs")
.expect("path should be valid"),
"nested/lib.rs"
);
}
#[test]
fn safe_directory_relative_item_path_accepts_root_download_paths() {
assert_eq!(
safe_directory_relative_item_path("", "src/main.rs").expect("path should be valid"),
"src/main.rs"
);
}
#[test]
fn safe_directory_relative_item_path_rejects_parent_segments() {
assert!(safe_directory_relative_item_path("src", "src/../outside.rs").is_err());
}
#[test]
fn safe_directory_relative_item_path_rejects_absolute_paths() {
assert!(safe_directory_relative_item_path("", "/tmp/outside.rs").is_err());
}
#[test]
fn safe_directory_relative_item_path_rejects_separator_aliases() {
assert!(safe_directory_relative_item_path("src", "src\\nested\\lib.rs").is_err());
}
#[test]
fn safe_directory_relative_item_path_rejects_empty_paths() {
assert!(safe_directory_relative_item_path("", "").is_err());
}
#[test]
fn safe_directory_relative_item_path_rejects_paths_outside_remote_root() {
assert!(safe_directory_relative_item_path("src", "other/lib.rs").is_err());
}
#[test]
fn redacts_credentials_in_display_urls() {
assert_eq!(
redact_url_for_display("https://user:secret@example.com:8443/path"),
"https://***@example.com:8443/path"
);
assert_eq!(
redact_url_for_display("https://gh-proxy.com/https://api.github.com"),
"https://gh-proxy.com/https://api.github.com"
);
}
}