use std::{
fs,
io::{self, IsTerminal},
path::{Path, PathBuf},
};
use anyhow::{Context, Result};
use dialoguer::{Confirm, Input, Select};
use crate::{
config::{AppConfig, ConfigPaths},
doctor::{DoctorRunArgs, run_doctor},
init::run_init,
sync::{SyncRequest, run_sync},
term::{
print_flow_header, print_path_row, print_section_title, print_status_success,
print_status_warn, print_tip,
},
};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum SetupSourceChoice {
AutoDetect,
OpenApi,
Database,
Django,
Flask,
Rails,
Laravel,
AspNet,
Strapi,
Url,
SkipSync,
NotSure,
}
impl SetupSourceChoice {
fn items() -> &'static [&'static str] {
&[
"Inspect project folder — find OpenAPI, DB, or framework (start here)",
"OpenAPI spec: URL (running server) or path to file",
"Database / datastore connection",
"Django project",
"Flask project",
"Rails project",
"Laravel project",
"ASP.NET project",
"Strapi project",
"Website / browser-login URL",
"Skip sync for now (I already have tools or will sync later)",
"I'm not sure yet",
]
}
fn from_index(index: usize) -> Self {
match index {
0 => Self::AutoDetect,
1 => Self::OpenApi,
2 => Self::Database,
3 => Self::Django,
4 => Self::Flask,
5 => Self::Rails,
6 => Self::Laravel,
7 => Self::AspNet,
8 => Self::Strapi,
9 => Self::Url,
10 => Self::SkipSync,
_ => Self::NotSure,
}
}
fn is_http_like(self) -> bool {
!matches!(
self,
Self::AutoDetect | Self::Database | Self::SkipSync | Self::NotSure
)
}
}
#[derive(Debug, Clone)]
struct SetupSyncOutcome {
did_sync: bool,
doctor_source: Option<SetupSourceChoice>,
}
impl SetupSyncOutcome {
fn skipped() -> Self {
Self {
did_sync: false,
doctor_source: None,
}
}
fn synced(source: SetupSourceChoice) -> Self {
Self {
did_sync: true,
doctor_source: Some(source),
}
}
}
#[derive(Debug, Clone)]
struct DetectedSyncSource {
label: String,
source: SetupSourceChoice,
request: SyncRequest,
}
pub async fn run_setup(paths: &ConfigPaths) -> Result<()> {
paths.ensure()?;
print_flow_header(
"setup",
Some("A guided path from install to a working terminal or web chat"),
);
print_path_row("app directory", &paths.root);
if dirs::home_dir()
.map(|h| h.join(".appctl") == paths.root)
.unwrap_or(false)
{
print_tip("~/.appctl: `cd` to your project before sync, or use `--app-dir other/.appctl`.");
}
if !io::stdin().is_terminal() {
print_non_interactive_setup(paths);
return Ok(());
}
ensure_provider(paths).await?;
let source = choose_source()?;
let outcome = maybe_run_sync(paths, source).await?;
maybe_run_doctor(paths, outcome).await;
print_next_steps(paths);
Ok(())
}
async fn ensure_provider(paths: &ConfigPaths) -> Result<()> {
let needs_provider = match AppConfig::load(paths) {
Ok(config) => config.providers.is_empty(),
Err(_) => true,
};
if needs_provider {
print_section_title("1. AI provider");
println!(" appctl needs one AI provider before chat, run, or serve can work.");
run_init(paths).await?;
} else {
print_section_title("1. AI provider");
print_status_success("provider already configured");
if Confirm::new()
.with_prompt("Reconfigure the AI provider now?")
.default(false)
.interact()?
{
run_init(paths).await?;
}
}
Ok(())
}
fn choose_source() -> Result<SetupSourceChoice> {
print_section_title("2. App tools");
println!(" appctl needs synced tools so the agent knows what it can do.");
let index = Select::new()
.with_prompt("What are you connecting appctl to?")
.items(SetupSourceChoice::items())
.default(0)
.interact()?;
Ok(SetupSourceChoice::from_index(index))
}
async fn maybe_run_sync(
paths: &ConfigPaths,
source: SetupSourceChoice,
) -> Result<SetupSyncOutcome> {
match source {
SetupSourceChoice::AutoDetect => run_auto_detected_sync(paths).await,
SetupSourceChoice::NotSure => run_not_sure_sync(paths).await,
_ => run_manual_source_sync(paths, source).await,
}
}
async fn run_not_sure_sync(paths: &ConfigPaths) -> Result<SetupSyncOutcome> {
print_section_title("2. App tools — not sure yet");
tip_if_home_global_appctl(paths);
print_tip("Scan this directory, or use `--app-dir` for another project’s `.appctl`.");
let items = [
"Scan my current directory for sources (OpenAPI / framework / SQLite)",
"Choose OpenAPI, database, or framework manually",
"Skip sync for now",
];
let selected = Select::new()
.with_prompt("What do you want to do?")
.items(items)
.default(0)
.interact()?;
match selected {
0 => run_auto_detected_sync(paths).await,
1 => {
let manual = choose_manual_source()?;
run_manual_source_sync(paths, manual).await
}
2 => {
print_status_warn("skipped sync");
print_tip("Run `appctl sync --help` when you are ready to add tools.");
Ok(SetupSyncOutcome::skipped())
}
_ => unreachable!(),
}
}
async fn run_manual_source_sync(
paths: &ConfigPaths,
source: SetupSourceChoice,
) -> Result<SetupSyncOutcome> {
if matches!(source, SetupSourceChoice::SkipSync) {
print_status_warn("skipped sync");
print_tip("Run `appctl sync --help` when you are ready to add tools.");
return Ok(SetupSyncOutcome::skipped());
}
let force = confirm_replace_existing_tools(paths)?;
let mut request = SyncRequest {
force,
..Default::default()
};
match source {
SetupSourceChoice::OpenApi => {
let scan_root = inspection_project_root(paths)?;
let spec_files = find_openapi_spec_files(&scan_root);
let default_line = spec_files.first().map(|p| openapi_prompt_default_path(p));
if default_line.is_none() {
print_tip(
"No openapi/swagger file found under this project. Use a file path, a live URL (server running), or go back and pick \"Inspect project\".",
);
}
request.openapi = Some(prompt_string(
"OpenAPI: URL or spec file path",
default_line.as_deref(),
)?);
request.base_url = prompt_optional("Base URL (optional, for calling the API)")?;
request.auth_header =
prompt_optional("Auth header (optional, e.g. Authorization: Bearer env:TOKEN)")?;
}
SetupSourceChoice::Database => {
request.db = Some(prompt_string(
"Database URL, e.g. sqlite:///path/app.db or postgres://...",
None,
)?);
}
SetupSourceChoice::Django => {
request.django = Some(prompt_path("Django project root", Some("."))?);
request.base_url = Some(prompt_string_nonempty(
"Base URL for HTTP tool calls (your running app)",
"http://127.0.0.1:8000",
)?);
}
SetupSourceChoice::Flask => {
request.flask = Some(prompt_path("Flask project root", Some("."))?);
request.base_url = Some(prompt_string_nonempty(
"Base URL for HTTP tool calls (your running app)",
"http://127.0.0.1:5000",
)?);
}
SetupSourceChoice::Rails => {
request.rails = Some(prompt_path("Rails project root", Some("."))?);
request.base_url = Some(prompt_string_nonempty(
"Base URL for HTTP tool calls (your running app)",
"http://127.0.0.1:3000",
)?);
}
SetupSourceChoice::Laravel => {
request.laravel = Some(prompt_path("Laravel project root", Some("."))?);
request.base_url = Some(prompt_string_nonempty(
"Base URL for HTTP tool calls (your running app)",
"http://127.0.0.1:8000",
)?);
}
SetupSourceChoice::AspNet => {
request.aspnet = Some(prompt_path("ASP.NET project root", Some("."))?);
request.base_url = Some(prompt_string_nonempty(
"Base URL for HTTP tool calls (your running app)",
"http://127.0.0.1:5000",
)?);
}
SetupSourceChoice::Strapi => {
request.strapi = Some(prompt_path("Strapi project root", Some("."))?);
request.base_url = Some(prompt_string_nonempty(
"Base URL for HTTP tool calls (your running app)",
"http://127.0.0.1:1337",
)?);
}
SetupSourceChoice::Url => {
request.url = Some(prompt_string("Website root URL", None)?);
request.login_url = prompt_optional("Login page URL (optional)")?;
request.login_user = prompt_optional("Login username (optional)")?;
request.login_password = prompt_optional("Login password (optional)")?;
}
SetupSourceChoice::AutoDetect
| SetupSourceChoice::SkipSync
| SetupSourceChoice::NotSure => {
unreachable!()
}
}
run_sync(paths.clone(), request).await?;
Ok(SetupSyncOutcome::synced(source))
}
async fn run_auto_detected_sync(paths: &ConfigPaths) -> Result<SetupSyncOutcome> {
let project_root = inspection_project_root(paths)?;
print_section_title("2a. Inspecting project");
print_path_row(
"inspection root (scan here for OpenAPI / code / DB files)",
&project_root,
);
tip_if_home_global_appctl(paths);
let candidates = detect_sync_sources(&project_root);
if candidates.is_empty() {
print_status_warn("no obvious sync source found");
print_tip("If your app exposes OpenAPI, choose OpenAPI and paste the URL or file path.");
print_tip("If you mainly need data tools, choose Database and paste a connection string.");
let manual = choose_manual_source()?;
return run_manual_source_sync(paths, manual).await;
}
let mut items: Vec<String> = candidates
.iter()
.map(|candidate| candidate.label.clone())
.collect();
items.push("Choose a source manually instead".to_string());
items.push("Skip sync for now".to_string());
let selected = Select::new()
.with_prompt("I found these likely app sources. Which should I sync?")
.items(&items)
.default(0)
.interact()?;
if selected == candidates.len() {
let manual = choose_manual_source()?;
return run_manual_source_sync(paths, manual).await;
}
if selected == candidates.len() + 1 {
print_status_warn("skipped sync");
return Ok(SetupSyncOutcome::skipped());
}
let candidate = &candidates[selected];
if matches!(candidate.source, SetupSourceChoice::Flask) {
print_tip(
"Flask: tools are inferred from Python in the project (introspection only). For a spec-driven API, prefer the OpenAPI menu. You will be asked for the HTTP `base_url` the tools should call; default is http://127.0.0.1:5000 if your `flask run` matches.",
);
}
let mut request = candidate.request.clone();
request.force = confirm_replace_existing_tools(paths)?;
fill_detected_missing_values(candidate.source, &mut request)?;
print_status_success(&format!("syncing detected source: {}", candidate.label));
run_sync(paths.clone(), request).await?;
Ok(SetupSyncOutcome::synced(candidate.source))
}
fn choose_manual_source() -> Result<SetupSourceChoice> {
let items = &SetupSourceChoice::items()[1..SetupSourceChoice::items().len() - 1];
let index = Select::new()
.with_prompt("Choose the source manually")
.items(items)
.default(0)
.interact()?;
Ok(SetupSourceChoice::from_index(index + 1))
}
fn confirm_replace_existing_tools(paths: &ConfigPaths) -> Result<bool> {
if paths.schema.exists() {
Confirm::new()
.with_prompt("Tools are already synced. Replace them with this setup sync?")
.default(false)
.interact()
.context("prompt failed")
} else {
Ok(false)
}
}
fn default_dev_base_url_for_setup(source: SetupSourceChoice) -> &'static str {
match source {
SetupSourceChoice::Django | SetupSourceChoice::Laravel => "http://127.0.0.1:8000",
SetupSourceChoice::Rails => "http://127.0.0.1:3000",
SetupSourceChoice::Strapi => "http://127.0.0.1:1337",
SetupSourceChoice::Flask | SetupSourceChoice::AspNet => "http://127.0.0.1:5000",
SetupSourceChoice::Url => "http://127.0.0.1:5000",
_ => "http://127.0.0.1:8080",
}
}
fn fill_detected_missing_values(
source: SetupSourceChoice,
request: &mut SyncRequest,
) -> Result<()> {
match source {
SetupSourceChoice::OpenApi => {
request.base_url = prompt_optional("Base URL for the running API (optional)")?;
request.auth_header = prompt_optional(
"Auth header for protected routes, e.g. Authorization: Bearer env:API_TOKEN (optional)",
)?;
}
source if source.is_http_like() => {
let def = default_dev_base_url_for_setup(source);
request.base_url = Some(prompt_string_nonempty(
"Base URL for HTTP tool calls (where this app is reachable; required for list/get/…)",
def,
)?);
}
_ => {}
}
Ok(())
}
fn inspection_project_root(paths: &ConfigPaths) -> Result<PathBuf> {
let cwd = std::env::current_dir().context("failed to get current working directory")?;
let home = dirs::home_dir();
Ok(inspection_project_root_inner(paths, home.as_deref(), &cwd))
}
fn inspection_project_root_inner(paths: &ConfigPaths, home: Option<&Path>, cwd: &Path) -> PathBuf {
if let Some(h) = home {
if Some(h) == paths.root.parent() {
return cwd.to_path_buf();
}
}
paths
.root
.parent()
.map(Path::to_path_buf)
.unwrap_or_else(|| paths.root.clone())
}
fn tip_if_home_global_appctl(paths: &ConfigPaths) {
let Some(h) = dirs::home_dir() else {
return;
};
if h.join(".appctl") == paths.root {
print_tip("Scan uses this directory only; config stays in ~/.appctl.");
}
}
fn is_openapi_spec_file_name(name: &str) -> bool {
matches!(
name.to_ascii_lowercase().as_str(),
"openapi.json"
| "openapi.yaml"
| "openapi.yml"
| "swagger.json"
| "swagger.yaml"
| "swagger.yml"
)
}
fn find_openapi_spec_files(project_root: &Path) -> Vec<PathBuf> {
let files = collect_project_files(project_root, 5);
let mut out: Vec<PathBuf> = files
.into_iter()
.filter(|p| {
p.file_name()
.and_then(|n| n.to_str())
.map(is_openapi_spec_file_name)
.unwrap_or(false)
})
.collect();
out.sort_by_key(|p| p.components().count());
out
}
fn openapi_prompt_default_path(file: &Path) -> String {
if let Ok(cwd) = std::env::current_dir() {
if let Ok(rel) = file.strip_prefix(&cwd) {
return rel.display().to_string();
}
}
file.display().to_string()
}
fn detect_sync_sources(project_root: &Path) -> Vec<DetectedSyncSource> {
let mut candidates = Vec::new();
let dirs = collect_project_dirs(project_root, 3);
let files = collect_project_files(project_root, 4);
for file in &files {
let Some(name) = file.file_name().and_then(|name| name.to_str()) else {
continue;
};
if is_openapi_spec_file_name(name) {
candidates.push(DetectedSyncSource {
label: format!("OpenAPI document: {}", short_path(project_root, file)),
source: SetupSourceChoice::OpenApi,
request: SyncRequest {
openapi: Some(file.display().to_string()),
..Default::default()
},
});
}
if is_likely_sqlite_file(file) && !is_junk_sqlite_path(file) {
candidates.push(DetectedSyncSource {
label: format!("SQLite database: {}", short_path(project_root, file)),
source: SetupSourceChoice::Database,
request: SyncRequest {
db: Some(format!("sqlite://{}", file.display())),
..Default::default()
},
});
}
}
for dir in dirs {
if dir.join("manage.py").exists() {
candidates.push(path_candidate(
project_root,
&dir,
"Django project",
SetupSourceChoice::Django,
|path| SyncRequest {
django: Some(path),
..Default::default()
},
));
}
if dir.join("artisan").exists() {
candidates.push(path_candidate(
project_root,
&dir,
"Laravel project",
SetupSourceChoice::Laravel,
|path| SyncRequest {
laravel: Some(path),
..Default::default()
},
));
}
if dir.join("config").join("routes.rb").exists() {
candidates.push(path_candidate(
project_root,
&dir,
"Rails project",
SetupSourceChoice::Rails,
|path| SyncRequest {
rails: Some(path),
..Default::default()
},
));
}
if dir.join("src").join("api").exists()
&& dir.join("package.json").exists()
&& file_contains(&dir.join("package.json"), "strapi")
{
candidates.push(path_candidate(
project_root,
&dir,
"Strapi project",
SetupSourceChoice::Strapi,
|path| SyncRequest {
strapi: Some(path),
..Default::default()
},
));
}
if has_aspnet_project_file(&dir) {
candidates.push(path_candidate(
project_root,
&dir,
"ASP.NET project",
SetupSourceChoice::AspNet,
|path| SyncRequest {
aspnet: Some(path),
..Default::default()
},
));
}
if looks_like_flask_project(&dir) {
candidates.push(path_candidate(
project_root,
&dir,
"Flask project",
SetupSourceChoice::Flask,
|path| SyncRequest {
flask: Some(path),
..Default::default()
},
));
}
}
candidates.truncate(12);
candidates
}
fn path_candidate(
project_root: &Path,
dir: &Path,
label: &str,
source: SetupSourceChoice,
build_request: impl FnOnce(PathBuf) -> SyncRequest,
) -> DetectedSyncSource {
DetectedSyncSource {
label: format!("{label}: {}", short_path(project_root, dir)),
source,
request: build_request(dir.to_path_buf()),
}
}
fn collect_project_dirs(root: &Path, max_depth: usize) -> Vec<PathBuf> {
let mut dirs = vec![root.to_path_buf()];
collect_project_dirs_inner(root, root, max_depth, &mut dirs);
dirs
}
fn collect_project_dirs_inner(root: &Path, dir: &Path, max_depth: usize, out: &mut Vec<PathBuf>) {
if depth_from(root, dir) >= max_depth {
return;
}
let Ok(entries) = fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
if !path.is_dir() || should_skip_dir(&path) {
continue;
}
out.push(path.clone());
collect_project_dirs_inner(root, &path, max_depth, out);
}
}
fn collect_project_files(root: &Path, max_depth: usize) -> Vec<PathBuf> {
let mut files = Vec::new();
collect_project_files_inner(root, root, max_depth, &mut files);
files
}
fn collect_project_files_inner(root: &Path, dir: &Path, max_depth: usize, out: &mut Vec<PathBuf>) {
if depth_from(root, dir) > max_depth {
return;
}
let Ok(entries) = fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
if !should_skip_dir(&path) {
collect_project_files_inner(root, &path, max_depth, out);
}
} else {
out.push(path);
}
}
}
fn depth_from(root: &Path, path: &Path) -> usize {
path.strip_prefix(root)
.map(|relative| relative.components().count())
.unwrap_or(0)
}
fn should_skip_dir(path: &Path) -> bool {
matches!(
path.file_name().and_then(|name| name.to_str()),
Some(
".appctl"
| ".config"
| ".continue"
| ".cursor"
| ".gemini"
| ".git"
| ".hg"
| ".local"
| ".svn"
| ".next"
| ".venv"
| "build"
| "dist"
| "node_modules"
| "target"
| "vendor"
| "venv"
)
)
}
fn is_junk_sqlite_path(path: &Path) -> bool {
for c in path.components() {
if let std::path::Component::Normal(name) = c {
if matches!(
name.to_string_lossy().as_ref(),
".config"
| ".cursor"
| ".continue"
| ".gemini"
| "node_modules"
| "target"
| "dist"
| "build"
| "vendor"
| "venv"
| ".venv"
) {
return true;
}
}
}
let s = path.to_string_lossy();
s.contains("/.config/")
|| s.contains("/.continue/")
|| s.contains("/.cursor/")
|| s.contains("/.gemini/")
|| s.contains("github-copilot")
|| s.contains("gcloud")
|| s.contains("Application Support")
}
fn is_likely_sqlite_file(path: &Path) -> bool {
if path.file_name().and_then(|name| name.to_str()) == Some("history.db") {
return false;
}
matches!(
path.extension().and_then(|ext| ext.to_str()),
Some("db" | "sqlite" | "sqlite3")
)
}
fn has_aspnet_project_file(dir: &Path) -> bool {
let Ok(entries) = fs::read_dir(dir) else {
return false;
};
entries.flatten().any(|entry| {
matches!(
entry.path().extension().and_then(|ext| ext.to_str()),
Some("csproj" | "fsproj" | "vbproj")
)
})
}
fn looks_like_flask_project(dir: &Path) -> bool {
if !dir.join("app.py").exists() && !dir.join("wsgi.py").exists() {
return false;
}
["requirements.txt", "pyproject.toml", "Pipfile"]
.iter()
.any(|name| file_contains(&dir.join(name), "flask"))
}
fn file_contains(path: &Path, needle: &str) -> bool {
fs::read_to_string(path)
.map(|content| content.to_ascii_lowercase().contains(needle))
.unwrap_or(false)
}
fn short_path(root: &Path, path: &Path) -> String {
path.strip_prefix(root)
.unwrap_or(path)
.display()
.to_string()
}
async fn maybe_run_doctor(paths: &ConfigPaths, outcome: SetupSyncOutcome) {
let Some(source) = outcome.doctor_source else {
return;
};
if !outcome.did_sync || !source.is_http_like() {
return;
}
print_section_title("3. Checks");
match run_doctor(
paths,
DoctorRunArgs {
write: true,
timeout_secs: 10,
},
)
.await
{
Ok(()) => print_status_success("doctor checks completed"),
Err(err) => {
print_status_warn("doctor could not finish");
print_tip(&format!(
"You can still continue. Later, run `appctl doctor --write` after the app is reachable. Detail: {err:#}"
));
}
}
}
fn print_next_steps(paths: &ConfigPaths) {
print_section_title("Ready");
print_path_row("app directory", &paths.root);
print_status_success("setup flow finished");
print_tip("Terminal: appctl chat");
print_tip("Web: appctl serve --open");
}
fn print_non_interactive_setup(paths: &ConfigPaths) {
print_section_title("Setup needs an interactive terminal");
print_path_row("app directory", &paths.root);
println!(" Run the guided setup in a terminal:");
println!(" appctl setup");
println!();
println!(" Advanced manual path:");
println!(" appctl init");
println!(" appctl sync --openapi <url-or-file> --base-url <running-api-url>");
println!(" appctl doctor --write");
println!(" appctl chat");
}
fn prompt_string(prompt: &str, default: Option<&str>) -> Result<String> {
let mut input = Input::<String>::new().with_prompt(prompt.to_string());
if let Some(default) = default {
input = input.default(default.to_string());
}
input.interact_text().context("prompt failed")
}
fn prompt_optional(prompt: &str) -> Result<Option<String>> {
let value = Input::<String>::new()
.with_prompt(prompt.to_string())
.allow_empty(true)
.interact_text()
.context("prompt failed")?;
let trimmed = value.trim();
if trimmed.is_empty() {
Ok(None)
} else {
Ok(Some(trimmed.to_string()))
}
}
fn prompt_string_nonempty(prompt: &str, default: &str) -> Result<String> {
let value = Input::<String>::new()
.with_prompt(prompt.to_string())
.default(default.to_string())
.interact_text()
.context("prompt failed")?;
let trimmed = value.trim();
if trimmed.is_empty() {
Ok(default.to_string())
} else {
Ok(trimmed.to_string())
}
}
fn prompt_path(prompt: &str, default: Option<&str>) -> Result<PathBuf> {
prompt_string(prompt, default).map(PathBuf::from)
}
#[cfg(test)]
mod tests {
use super::{
ConfigPaths, SetupSourceChoice, detect_sync_sources, find_openapi_spec_files,
inspection_project_root_inner,
};
use std::fs;
use std::path::PathBuf;
use tempfile::tempdir;
#[test]
fn setup_source_choice_maps_known_indexes() {
assert_eq!(
SetupSourceChoice::from_index(0),
SetupSourceChoice::AutoDetect
);
assert_eq!(SetupSourceChoice::from_index(1), SetupSourceChoice::OpenApi);
assert_eq!(
SetupSourceChoice::from_index(2),
SetupSourceChoice::Database
);
assert_eq!(
SetupSourceChoice::from_index(11),
SetupSourceChoice::NotSure
);
}
#[test]
fn doctor_runs_only_for_http_like_sources() {
assert!(SetupSourceChoice::OpenApi.is_http_like());
assert!(SetupSourceChoice::Django.is_http_like());
assert!(!SetupSourceChoice::Database.is_http_like());
assert!(!SetupSourceChoice::SkipSync.is_http_like());
}
#[test]
fn setup_detects_openapi_file_and_sqlite_db() {
let dir = tempdir().unwrap();
fs::write(dir.path().join("openapi.json"), "{}").unwrap();
fs::write(dir.path().join("app.sqlite"), "").unwrap();
let found = detect_sync_sources(dir.path());
assert!(
found
.iter()
.any(|candidate| candidate.source == SetupSourceChoice::OpenApi)
);
assert!(
found
.iter()
.any(|candidate| candidate.source == SetupSourceChoice::Database)
);
}
#[test]
fn setup_detects_framework_markers() {
let dir = tempdir().unwrap();
fs::write(dir.path().join("manage.py"), "").unwrap();
fs::create_dir_all(dir.path().join("config")).unwrap();
fs::write(dir.path().join("config").join("routes.rb"), "").unwrap();
let found = detect_sync_sources(dir.path());
assert!(
found
.iter()
.any(|candidate| candidate.source == SetupSourceChoice::Django)
);
assert!(
found
.iter()
.any(|candidate| candidate.source == SetupSourceChoice::Rails)
);
}
#[test]
fn inspection_root_uses_cwd_when_app_dir_is_home_dot_appctl() {
let home = PathBuf::from("/Users/person");
let paths = ConfigPaths::new(home.join(".appctl"));
let cwd = home.join("open").join("source").join("Quorum");
let root = inspection_project_root_inner(&paths, Some(home.as_path()), &cwd);
assert_eq!(root, cwd);
}
#[test]
fn inspection_root_uses_project_parent_for_nested_appctl() {
let home = PathBuf::from("/Users/person");
let project = home.join("repos").join("myapp");
let paths = ConfigPaths::new(project.join(".appctl"));
let cwd = home.join("somewhere-else");
let root = inspection_project_root_inner(&paths, Some(home.as_path()), &cwd);
assert_eq!(root, project);
}
#[test]
fn openapi_spec_search_prefers_shallowest_file() {
let dir = tempdir().unwrap();
fs::create_dir_all(dir.path().join("nested")).unwrap();
fs::write(dir.path().join("nested").join("openapi.json"), "{}").unwrap();
fs::write(dir.path().join("openapi.json"), "{}").unwrap();
let found = find_openapi_spec_files(dir.path());
assert_eq!(found[0], dir.path().join("openapi.json"));
}
}