use std::path::Path;
pub fn json_ok() -> String {
r#"{"ok":true}"#.to_string()
}
pub fn json_err(msg: &str) -> String {
serde_json::json!({ "error": msg }).to_string()
}
pub fn extract_query_param(qs: &str, key: &str) -> Option<String> {
for pair in qs.split('&') {
let Some((k, v)) = pair.split_once('=') else {
continue;
};
if k == key {
return Some(percent_decode_query_component(v));
}
}
None
}
pub fn percent_decode_query_component(s: &str) -> String {
let mut out: Vec<u8> = Vec::with_capacity(s.len());
let b = s.as_bytes();
let mut i = 0;
while i < b.len() {
match b[i] {
b'+' => {
out.push(b' ');
i += 1;
}
b'%' if i + 2 < b.len() => {
let h1 = (b[i + 1] as char).to_digit(16);
let h2 = (b[i + 2] as char).to_digit(16);
if let (Some(a), Some(d)) = (h1, h2) {
out.push(((a << 4) | d) as u8);
i += 3;
} else {
out.push(b'%');
i += 1;
}
}
_ => {
out.push(b[i]);
i += 1;
}
}
}
String::from_utf8_lossy(&out).into_owned()
}
pub fn normalize_dashboard_demo_path(path: &str) -> String {
let trimmed = path.trim();
if trimmed.is_empty() {
return String::new();
}
let candidate = Path::new(trimmed);
if candidate.is_absolute() || is_windows_absolute_path(trimmed) {
return trimmed.to_string();
}
let mut p = trimmed;
while p.starts_with("./") || p.starts_with(".\\") {
p = &p[2..];
}
p.trim_start_matches(['\\', '/'])
.replace('\\', std::path::MAIN_SEPARATOR_STR)
}
pub fn is_windows_absolute_path(path: &str) -> bool {
let bytes = path.as_bytes();
if bytes.len() >= 3
&& bytes[0].is_ascii_alphabetic()
&& bytes[1] == b':'
&& matches!(bytes[2], b'\\' | b'/')
{
return true;
}
path.starts_with("\\\\") || path.starts_with("//")
}
pub fn detect_project_root_for_dashboard() -> String {
if let Ok(explicit) = std::env::var("LEAN_CTX_DASHBOARD_PROJECT") {
if !explicit.trim().is_empty() {
return promote_to_git_root(&explicit);
}
}
if let Some(session) = crate::core::session::SessionState::load_latest() {
if let Some(root) = session.project_root.as_deref() {
if !root.trim().is_empty() {
if let Some(git_root) = git_root_for(root) {
return git_root;
}
if is_real_project(root) {
return root.to_string();
}
}
}
if let Some(cwd) = session.shell_cwd.as_deref() {
if !cwd.trim().is_empty() {
let r = crate::core::protocol::detect_project_root_or_cwd(cwd);
return promote_to_git_root(&r);
}
}
if let Some(last) = session.files_touched.last() {
if !last.path.trim().is_empty() {
if let Some(parent) = Path::new(&last.path).parent() {
let p = parent.to_string_lossy().to_string();
let r = crate::core::protocol::detect_project_root_or_cwd(&p);
return promote_to_git_root(&r);
}
}
}
}
let cwd = std::env::current_dir()
.map_or_else(|_| ".".to_string(), |p| p.to_string_lossy().to_string());
let r = crate::core::protocol::detect_project_root_or_cwd(&cwd);
promote_to_git_root(&r)
}
fn is_real_project(path: &str) -> bool {
let p = Path::new(path);
if !p.is_dir() {
return false;
}
const MARKERS: &[&str] = &[
".git",
"Cargo.toml",
"package.json",
"go.mod",
"pyproject.toml",
"requirements.txt",
"pom.xml",
"build.gradle",
"CMakeLists.txt",
".lean-ctx.toml",
];
MARKERS.iter().any(|m| p.join(m).exists())
}
fn promote_to_git_root(path: &str) -> String {
git_root_for(path).unwrap_or_else(|| path.to_string())
}
fn git_root_for(path: &str) -> Option<String> {
let mut p = Path::new(path);
loop {
let git = p.join(".git");
if git.exists() {
return Some(p.to_string_lossy().to_string());
}
p = p.parent()?;
}
}