Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
213 changes: 213 additions & 0 deletions src/doctor/api.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
//! Public API for the doctor module.
//!
//! This module provides the main library entry points for programmatic usage
//! of the doctor functionality without CLI dependencies.
//!
//! # Examples
//!
//! ## Run All Checks with Auto-Fix
//!
//! ```rust,no_run
//! use dx_scope::{DoctorRunOptions, FoundConfig};
//! use dx_scope::doctor::run;
//!
//! #[tokio::main]
//! async fn main() -> anyhow::Result<()> {
//! // Load configuration
//! let working_dir = std::env::current_dir()?;
//! let config = FoundConfig::empty(working_dir);
//!
//! // Configure options with auto-fix enabled
//! let options = DoctorRunOptions::with_fixes();
//!
//! let result = run(&config, options).await?;
//!
//! println!("Success: {}", result.did_succeed);
//! println!("Passed: {}", result.succeeded_groups.len());
//! println!("Failed: {}", result.failed_group.len());
//!
//! Ok(())
//! }
//! ```

use crate::doctor::check::{DefaultDoctorActionRun, DefaultGlobWalker};
use crate::doctor::file_cache::{FileBasedCache, FileCache, NoOpCache};
use crate::doctor::options::DoctorRunOptions;
use crate::doctor::runner::{GroupActionContainer, PathRunResult, RunGroups, compute_group_order};
use crate::shared::directories;
use crate::shared::prelude::{DefaultExecutionProvider, ExecutionProvider, FoundConfig};
use anyhow::Result;
use std::collections::{BTreeMap, BTreeSet};
use std::sync::Arc;
use tracing::{info, warn};

/// Run doctor health checks.
///
/// This is the main library entry point for running doctor checks programmatically.
/// It runs health checks defined in the configuration and optionally applies fixes.
///
/// # Arguments
///
/// * `config` - Loaded scope configuration
/// * `options` - Doctor run options (groups, fix mode, cache settings)
///
/// # Returns
///
/// Returns `PathRunResult` containing:
/// - `did_succeed`: Overall success/failure
/// - `succeeded_groups`: Names of groups that passed
/// - `failed_group`: Names of groups that failed
/// - `skipped_group`: Names of groups that were skipped
/// - `group_reports`: Detailed reports for each group
///
/// # Examples
///
/// ```rust
/// use dx_scope::{DoctorRunOptions, FoundConfig};
/// use dx_scope::doctor::run;
///
/// let working_dir = std::env::current_dir().unwrap();
/// let config = FoundConfig::empty(working_dir);
/// let options = DoctorRunOptions::with_fixes();
/// // Call: run(&config, options).await
/// ```
///
/// # Note on Interaction
///
/// When `options.run_fix` is true, fixes will be applied automatically without prompting.
/// For non-interactive/CI environments, this is the recommended mode.
pub async fn run(
config: &FoundConfig,
options: DoctorRunOptions,
) -> Result<PathRunResult>
{
info!("Starting doctor run");

// Get cache implementation
let file_cache: Arc<dyn FileCache> = if options.no_cache {
Arc::<NoOpCache>::default()
} else {
let cache_dir_path = options.cache_dir.clone().unwrap_or_else(|| {
directories::cache()
.expect("Unable to determine cache directory")
.join("scope")
});

let cache_path = cache_dir_path.join("cache-file.json");

match FileBasedCache::new(&cache_path) {
Ok(cache) => Arc::new(cache),
Err(e) => {
warn!("Unable to create cache {:?}", e);
Arc::<NoOpCache>::default()
}
}
};

// Get execution provider
let exec_runner: Arc<dyn ExecutionProvider> = Arc::new(DefaultExecutionProvider::default());
let glob_walker = Arc::new(DefaultGlobWalker::default());

// Build group containers and desired groups set
let mut groups = BTreeMap::new();
let mut desired_groups = BTreeSet::new();
let run_fix = options.run_fix;

for group in config.doctor_group.values() {
let should_group_run = match &options.only_groups {
None => group.run_by_default,
Some(names) => names.contains(&group.metadata.name().to_string()),
};

let mut action_runs = Vec::new();

for action in &group.actions {
let run = DefaultDoctorActionRun {
model: group.clone(),
action: action.clone(),
working_dir: config.working_dir.clone(),
file_cache: file_cache.clone(),
run_fix,
exec_runner: exec_runner.clone(),
glob_walker: glob_walker.clone(),
known_errors: config.known_error.clone(),
};

action_runs.push(run);
}

let container = GroupActionContainer::new(
group.clone(),
action_runs,
exec_runner.clone(),
config.working_dir.clone(),
config.bin_path.clone(),
);

let group_name = container.group_name().to_string();
groups.insert(group_name.clone(), container);

if should_group_run {
desired_groups.insert(group_name);
}
}

// Compute group order
let all_paths = compute_group_order(&config.doctor_group, desired_groups);

if all_paths.is_empty() {
warn!("Could not find any tasks to execute");
}

// Execute groups
let run_groups = RunGroups {
group_actions: groups,
all_paths,
yolo: options.run_fix, // Auto-approve fixes when run_fix is enabled
};
let result = run_groups.execute().await?;

// Persist cache
if let Err(e) = file_cache.persist().await {
info!("Unable to store cache {:?}", e);
warn!("Unable to update cache, re-runs may redo work");
}

info!(
"Doctor run completed: {} succeeded, {} failed, {} skipped",
result.succeeded_groups.len(),
result.failed_group.len(),
result.skipped_group.len()
);

Ok(result)
}

/// List available doctor checks.
///
/// Returns information about all available doctor checks and groups
/// defined in the configuration.
///
/// # Arguments
///
/// * `config` - Loaded scope configuration
///
/// # Returns
///
/// Returns a vector of doctor groups with their checks.
///
/// # Examples
///
/// ```rust
/// use dx_scope::FoundConfig;
/// use dx_scope::doctor::list;
///
/// let working_dir = std::env::current_dir().unwrap();
/// let config = FoundConfig::empty(working_dir);
/// // Call: list(&config).await
/// // Then iterate: for group in groups { ... }
/// ```
pub async fn list(config: &FoundConfig) -> Result<Vec<crate::shared::prelude::DoctorGroup>> {
let order = super::commands::generate_doctor_list(config);
Ok(order.clone())
}
5 changes: 4 additions & 1 deletion src/doctor/check.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ use std::{cmp, vec};
use tokio::io::BufReader;
use tracing::debug;

use crate::internal::prompts::AutoApprove;
use crate::models::HelpMetadata;
use crate::prelude::{
ActionReport, ActionReportBuilder, ActionTaskReport, DoctorCommand, KnownError,
Expand Down Expand Up @@ -672,7 +673,9 @@ impl DefaultDoctorActionRun {
.collect::<Vec<String>>();

let buffer = BufReader::new(StringVecReader::new(&lines));
analyze::process_lines(&self.known_errors, &self.working_dir, buffer).await
// Use AutoApprove for self-healing: known errors during doctor runs
// are automatically fixed without user prompts
analyze::process_lines(&self.known_errors, &self.working_dir, buffer, &AutoApprove).await
}
}

Expand Down
12 changes: 7 additions & 5 deletions src/doctor/commands/run.rs
Original file line number Diff line number Diff line change
Expand Up @@ -54,12 +54,14 @@ fn get_cache(args: &DoctorRunArgs) -> Arc<dyn FileCache> {
let old_default_cache_path = PathBuf::from("/tmp/scope/cache-file.json");

// Handle backward compatibility: migrate from old location to new location
if cache_dir != "/tmp/scope"
let should_migrate = cache_dir != "/tmp/scope"
&& old_default_cache_path.exists()
&& !cache_path.exists()
&& let Err(e) = migrate_old_cache(&old_default_cache_path, &cache_path)
{
warn!("Unable to migrate cache from old location: {:?}", e);
&& !cache_path.exists();

if should_migrate {
if let Err(e) = migrate_old_cache(&old_default_cache_path, &cache_path) {
warn!("Unable to migrate cache from old location: {:?}", e);
}
}

match FileBasedCache::new(&cache_path) {
Expand Down
9 changes: 9 additions & 0 deletions src/doctor/mod.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
mod api;
mod check;
mod cli;
mod commands;
mod error;
mod file_cache;
pub mod options;
mod runner;
#[cfg(test)]
mod tests;
Expand All @@ -12,3 +14,10 @@ pub mod prelude {
pub use super::cli::doctor_root;
pub use super::commands::generate_doctor_list;
}

// Re-export key types for library usage
pub use options::DoctorRunOptions;
pub use runner::PathRunResult;

// Public API functions
pub use api::{run, list};
41 changes: 30 additions & 11 deletions src/doctor/runner.rs
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
use super::check::{ActionRunResult, ActionRunStatus, DoctorActionRun};
use crate::doctor::check::RuntimeError;
use crate::internal::prompts::UserInteraction;
use crate::models::HelpMetadata;
use crate::prelude::{
ActionReport, ActionTaskReport, CaptureOpts, ExecutionProvider, GroupReport, OutputDestination,
SkipSpec, generate_env_vars, progress_bar_without_pos,
use crate::models::prelude::SkipSpec;
use crate::shared::prelude::{
ActionReport, ActionTaskReport, CaptureOpts, DoctorGroup, ExecutionProvider, GroupReport,
OutputDestination, generate_env_vars, progress_bar_without_pos,
};
use crate::report_stdout;
use crate::shared::prelude::DoctorGroup;
use anyhow::Result;
use colored::Colorize;
use opentelemetry::trace::Status;
Expand Down Expand Up @@ -350,6 +350,10 @@ where
}
}

/// Prompt the user for confirmation using the inquire crate.
///
/// This function wraps inquire::Confirm and handles TTY detection.
/// It's used when `yolo` mode is disabled.
fn prompt_user(prompt_text: &str, maybe_help_text: &Option<String>) -> bool {
tracing_indicatif::suspend_tracing_indicatif(|| {
let prompt = {
Expand All @@ -364,14 +368,31 @@ fn prompt_user(prompt_text: &str, maybe_help_text: &Option<String>) -> bool {
})
}

/// Auto-approve all prompts.
///
/// This function automatically approves all prompts, used in `yolo` mode
/// or when running non-interactively.
fn auto_approve(prompt_text: &str, maybe_help_text: &Option<String>) -> bool {
println!("{} Yes (auto-approved)", prompt_text);
info!(target: "progress", prompt = %prompt_text, "Auto-approved");
if let Some(help_text) = maybe_help_text {
println!("[{}]", help_text);
debug!(target: "progress", help = %help_text, "Auto-approve help text");
}
true
}

/// Create a prompt function from a UserInteraction implementation.
///
/// This bridges the gap between the trait-based UserInteraction interface
/// and the function pointer interface used by DoctorActionRun.
#[allow(dead_code)]
pub fn make_prompt_fn<U: UserInteraction>(user_interaction: &U) -> impl Fn(&str, &Option<String>) -> bool + '_ {
move |prompt_text: &str, maybe_help_text: &Option<String>| {
tracing_indicatif::suspend_tracing_indicatif(|| {
user_interaction.confirm(prompt_text, maybe_help_text.as_deref())
})
}
}

async fn report_action_output<T>(
group_name: &str,
action: &T,
Expand Down Expand Up @@ -445,10 +466,8 @@ async fn print_pretty_result(
let task_reports = action_task_reports_for_display(&result.action_report);
for task in task_reports {
if let Some(text) = task.output {
let line_prefix = format!("{group_name}/{action_name}");
for line in text.lines() {
let output_line = format!("{}: {}", line_prefix.dimmed(), line);
report_stdout!("{}", output_line);
error!(target: "user", group = group_name, action = action_name, "{}", line);
}
}
}
Expand Down Expand Up @@ -527,7 +546,7 @@ mod tests {
};
use crate::doctor::runner::{GroupActionContainer, RunGroups, compute_group_order};
use crate::doctor::tests::{group_noop, make_root_model_additional};
use crate::prelude::{ActionReport, ActionTaskReport, MockExecutionProvider};
use crate::shared::prelude::{ActionReport, ActionTaskReport, MockExecutionProvider};
use anyhow::Result;
use std::collections::{BTreeMap, BTreeSet};
use std::sync::Arc;
Expand Down
Loading