use std::collections::{HashMap, HashSet};
use crate::change::{Change, ChangeId};
use crate::config::FollowConfig;
use crate::edit::{FlakeEdit, InputMap};
use crate::follows::{
AttrPath, Edge, EdgeOrigin, FollowsGraph, Segment, is_follows_reference_to_parent,
};
use crate::input::Range;
use crate::lock::{FlakeLock, NestedInput};
use crate::validate;
use super::super::super::editor::Editor;
use super::super::super::state::AppState;
use super::super::{Error, Result};
use super::load_follow_context;
const SENTINEL_ALREADY_DEDUPLICATED: &str = "All inputs are already deduplicated.";
pub fn run(editor: &Editor, flake_edit: &mut FlakeEdit, state: &AppState) -> Result<()> {
run_impl(editor, flake_edit, state, false)
}
#[doc(hidden)]
pub fn run_in_memory(
flake_text: &str,
lock_text: &str,
follow_config: &FollowConfig,
) -> Result<Option<String>> {
let mut flake_edit = FlakeEdit::from_text(flake_text)?;
let lock = FlakeLock::read_from_str(lock_text)?;
let nested_inputs = lock.nested_inputs();
if nested_inputs.is_empty() {
return Ok(None);
}
let inputs = flake_edit.list().clone();
if inputs.is_empty() {
return Ok(None);
}
let top_level_inputs: HashSet<String> = inputs.keys().cloned().collect();
let lock_graph = FollowsGraph::from_nested_inputs(&nested_inputs);
let graph = FollowsGraph::from_declared_and_lock_graph(&inputs, &lock_graph);
let Some(plan) = build_plan(
flake_text,
&nested_inputs,
top_level_inputs,
&inputs,
&graph,
follow_config,
) else {
return Ok(None);
};
let applied = apply_plan_text(flake_text, &inputs, &nested_inputs, &lock_graph, &plan)?;
Ok((applied.current_text != flake_text).then_some(applied.current_text))
}
pub fn run_batch(
paths: &[std::path::PathBuf],
transitive: Option<usize>,
depth: Option<usize>,
args: &crate::cli::CliArgs,
) -> Result<()> {
use std::path::PathBuf;
let mut errors: Vec<(PathBuf, Box<Error>)> = Vec::new();
for flake_path in paths {
let lock_path = flake_path
.parent()
.map(|p| p.join("flake.lock"))
.unwrap_or_else(|| PathBuf::from("flake.lock"));
let editor = match Editor::from_path(flake_path.clone()) {
Ok(e) => e,
Err(source) => {
errors.push((
flake_path.clone(),
Box::new(Error::FlakeNotFound {
path: flake_path.clone(),
source,
}),
));
continue;
}
};
let mut flake_edit = match editor.create_flake_edit() {
Ok(fe) => fe,
Err(e) => {
errors.push((flake_path.clone(), Box::new(e.into())));
continue;
}
};
let mut state = match AppState::new(flake_path.clone(), args.config().map(PathBuf::from)) {
Ok(s) => s
.with_diff(args.diff())
.with_no_lock(args.no_lock())
.with_lock_offline(true)
.with_interactive(false)
.with_lock_file(Some(lock_path))
.with_no_cache(args.no_cache())
.with_cache_path(args.cache().map(PathBuf::from)),
Err(e) => {
errors.push((flake_path.clone(), Box::new(e.into())));
continue;
}
};
if let Some(min) = transitive {
state.config.follow.transitive_min = min;
}
if let Some(max) = depth {
state.config.follow.max_depth = Some(max);
}
if let Err(e) = run_impl(&editor, &mut flake_edit, &state, true) {
errors.push((flake_path.clone(), Box::new(e)));
}
}
if errors.is_empty() {
Ok(())
} else {
Err(Error::Batch { failures: errors })
}
}
struct AnalysisCtx<'a> {
nested_inputs: &'a [NestedInput],
top_level_inputs: HashSet<String>,
inputs: &'a crate::edit::InputMap,
graph: &'a FollowsGraph,
existing_follows: &'a HashSet<AttrPath>,
follow_config: &'a FollowConfig,
max_depth: Option<usize>,
transitive_min: usize,
}
#[derive(Default)]
struct FollowPlan {
to_follow: Vec<(AttrPath, AttrPath)>,
to_unfollow: Vec<AttrPath>,
toplevel_follows: Vec<(AttrPath, AttrPath)>,
toplevel_adds: Vec<(String, String)>,
seen_nested: HashSet<AttrPath>,
}
impl FollowPlan {
fn has_pending(&self) -> bool {
!self.to_follow.is_empty()
|| !self.to_unfollow.is_empty()
|| !self.toplevel_follows.is_empty()
|| !self.toplevel_adds.is_empty()
}
}
#[derive(Default)]
struct AppliedPlan {
current_text: String,
applied_follows: Vec<(AttrPath, AttrPath)>,
unfollowed: Vec<AttrPath>,
warnings: Vec<validate::ValidationError>,
}
fn run_impl(
editor: &Editor,
flake_edit: &mut FlakeEdit,
state: &AppState,
quiet: bool,
) -> Result<()> {
let Some(ctx) = load_follow_context(flake_edit, state)? else {
if !quiet {
println!("Nothing to deduplicate.");
}
return Ok(());
};
let lock_graph = FollowsGraph::from_nested_inputs(&ctx.nested_inputs);
let graph = FollowsGraph::from_declared_and_lock_graph(&ctx.inputs, &lock_graph);
let Some(plan) = build_plan(
&editor.text(),
&ctx.nested_inputs,
ctx.top_level_inputs.clone(),
&ctx.inputs,
&graph,
&state.config.follow,
) else {
if !quiet {
println!("{SENTINEL_ALREADY_DEDUPLICATED}");
}
return Ok(());
};
let applied = apply_plan_text(
&editor.text(),
&ctx.inputs,
&ctx.nested_inputs,
&lock_graph,
&plan,
)?;
render_summary(editor, state, &applied, quiet)
}
fn build_plan(
source_text: &str,
nested_inputs: &[NestedInput],
top_level_inputs: HashSet<String>,
inputs: &InputMap,
graph: &FollowsGraph,
follow_config: &FollowConfig,
) -> Option<FollowPlan> {
let existing_follows: HashSet<AttrPath> = graph.declared_sources();
let transitive_min = follow_config.transitive_min();
let max_depth = follow_config.max_depth;
let to_unfollow = seed_unfollow_set(graph, max_depth);
let mut graph_for_discovery = graph.clone();
graph_for_discovery.drop_edges_with_sources(&to_unfollow);
let mut ax = AnalysisCtx {
nested_inputs,
top_level_inputs,
inputs,
graph: &graph_for_discovery,
existing_follows: &existing_follows,
follow_config,
max_depth,
transitive_min,
};
let mut plan = FollowPlan {
to_unfollow,
..FollowPlan::default()
};
if transitive_min > 0 {
let direct_groups = collect_direct_groups(&ax, &plan);
emit_direct_promotions(&ax, source_text, direct_groups, &mut plan);
for (name, _) in &plan.toplevel_adds {
ax.top_level_inputs.insert(name.clone());
}
let transitive_groups = collect_transitive_groups(&ax, &plan);
emit_transitive_promotions(&ax, transitive_groups, &mut plan);
}
collect_direct_candidates(&ax, &mut plan);
scrub_redundant(&graph_for_discovery, &mut plan);
if !plan.has_pending() {
return None;
}
Some(plan)
}
fn seed_unfollow_set(graph: &FollowsGraph, max_depth: Option<usize>) -> Vec<AttrPath> {
let mut to_unfollow: Vec<AttrPath> = graph
.stale_edges()
.into_iter()
.map(|e| e.source.clone())
.collect();
to_unfollow.extend(graph.stale_nulled_sources().into_iter().cloned());
let stale_set: HashSet<AttrPath> = to_unfollow.iter().cloned().collect();
for edge in graph.declared_edges() {
if stale_set.contains(&edge.source) {
continue;
}
if edge.source.len() < 3 {
continue;
}
if let Some(max) = max_depth
&& edge.source.len() > max + 1
{
continue;
}
if graph.lock_routes_to(&edge.source, &edge.follows, Some(edge), &[]) {
tracing::debug!(
"Marking redundant follow for removal: {} -> {} (covered by upstream propagation)",
edge.source,
edge.follows,
);
to_unfollow.push(edge.source.clone());
}
}
to_unfollow.sort();
to_unfollow.dedup();
to_unfollow
}
fn within_depth(path: &AttrPath, max_depth: Option<usize>) -> bool {
if path.len() < 2 {
return false;
}
max_depth.is_none_or(|m| path.len() <= m + 1)
}
fn ancestor_overrides_subtree(
nested_path: &AttrPath,
target: &AttrPath,
graph: &FollowsGraph,
) -> bool {
if nested_path.len() < 3 {
return false;
}
let segs = nested_path.segments();
let last = nested_path.last();
let nulled = graph.declared_nulled();
for prefix_len in 1..nested_path.len() - 1 {
let mut candidate = AttrPath::new(segs[0].clone());
for seg in &segs[1..prefix_len] {
candidate.push(seg.clone());
}
candidate.push(last.clone());
if nulled.contains(&candidate) {
return true;
}
for edge in graph.outgoing(&candidate) {
if !matches!(edge.origin, EdgeOrigin::Declared { .. }) {
continue;
}
if &edge.follows != target {
return true;
}
}
}
false
}
fn collect_direct_candidates(ax: &AnalysisCtx<'_>, plan: &mut FollowPlan) {
let mut iter: Vec<&NestedInput> = ax.nested_inputs.iter().collect();
iter.sort_by(|a, b| {
a.path
.len()
.cmp(&b.path.len())
.then_with(|| a.path.cmp(&b.path))
});
for nested in iter {
let extra_edges: Vec<(AttrPath, AttrPath)> = if nested.path.len() >= 3 {
plan.to_follow.to_vec()
} else {
Vec::new()
};
let Some(target_path) = resolve_direct_candidate(ax, nested, &extra_edges) else {
continue;
};
plan.seen_nested.insert(nested.path.clone());
plan.to_follow.push((nested.path.clone(), target_path));
}
}
fn resolve_direct_candidate(
ax: &AnalysisCtx<'_>,
nested: &NestedInput,
prior_emissions: &[(AttrPath, AttrPath)],
) -> Option<AttrPath> {
if !within_depth(&nested.path, ax.max_depth) {
return None;
}
let parent = nested.path.first().as_str();
let nested_name = nested.path.last().as_str();
let path_display = nested.path.to_string();
if ax.follow_config.is_ignored(&path_display, nested_name) {
tracing::debug!("Skipping {}: ignored by config", path_display);
return None;
}
if ax.existing_follows.contains(&nested.path) {
tracing::debug!("Skipping {}: already follows in flake.nix", path_display);
return None;
}
let target = ax
.top_level_inputs
.iter()
.find(|top| ax.follow_config.can_follow(nested_name, top))?;
if let Some(target_input) = ax.inputs.get(target.as_str())
&& is_follows_reference_to_parent(target_input.url(), parent)
{
tracing::debug!(
"Skipping {} -> {}: would create cycle (target follows {}/...)",
path_display,
target,
parent,
);
return None;
}
let target_path = match Segment::from_unquoted(target.clone()) {
Ok(seg) => AttrPath::new(seg),
Err(e) => {
tracing::warn!("Skipping {path_display} -> {target}: invalid input name: {e}");
return None;
}
};
if ancestor_overrides_subtree(&nested.path, &target_path, ax.graph) {
tracing::debug!(
"Skipping {} -> {}: ancestor declares a different follows for the same trailing name",
path_display,
target,
);
return None;
}
let proposed = Edge {
source: nested.path.clone(),
follows: target_path.clone(),
origin: EdgeOrigin::Declared {
range: Range { start: 0, end: 0 },
},
};
if ax.graph.would_create_cycle(&proposed) {
tracing::debug!(
"Skipping {} -> {}: would create cycle (multi-hop or lockfile-resolved)",
path_display,
target,
);
return None;
}
if ax
.graph
.lock_routes_to(&nested.path, &target_path, None, prior_emissions)
{
tracing::debug!(
"Skipping {} -> {}: lockfile already routes via upstream propagation",
path_display,
target,
);
return None;
}
Some(target_path)
}
fn collect_transitive_groups(
ax: &AnalysisCtx<'_>,
plan: &FollowPlan,
) -> HashMap<String, HashMap<AttrPath, Vec<AttrPath>>> {
let mut groups: HashMap<String, HashMap<AttrPath, Vec<AttrPath>>> = HashMap::new();
for nested in ax.nested_inputs.iter() {
let Some((top_level_name, transitive_target)) =
resolve_transitive_candidate(ax, plan, nested)
else {
continue;
};
groups
.entry(top_level_name)
.or_default()
.entry(transitive_target)
.or_default()
.push(nested.path.clone());
}
groups
}
fn resolve_transitive_candidate(
ax: &AnalysisCtx<'_>,
plan: &FollowPlan,
nested: &NestedInput,
) -> Option<(String, AttrPath)> {
if !within_depth(&nested.path, ax.max_depth) {
return None;
}
let nested_name = nested.path.last().as_str();
let parent = nested.path.first().as_str();
let path_display = nested.path.to_string();
if ax.follow_config.is_ignored(&path_display, nested_name) {
return None;
}
if ax.existing_follows.contains(&nested.path) || plan.seen_nested.contains(&nested.path) {
return None;
}
if ax
.top_level_inputs
.iter()
.any(|top| ax.follow_config.can_follow(nested_name, top))
{
return None;
}
let transitive_target = nested.follows.as_ref()?;
if ancestor_overrides_subtree(&nested.path, transitive_target, ax.graph) {
return None;
}
if transitive_target.len() < 2 {
return None;
}
if transitive_target.last().as_str() == nested_name {
return None;
}
let top_level_name = ax
.follow_config
.resolve_alias(nested_name)
.unwrap_or(nested_name)
.to_string();
if ax.top_level_inputs.contains(&top_level_name) {
return None;
}
if let Some(target_input) = ax.inputs.get(transitive_target.first().as_str())
&& is_follows_reference_to_parent(target_input.url(), parent)
{
return None;
}
let proposed = Edge {
source: nested.path.clone(),
follows: transitive_target.clone(),
origin: EdgeOrigin::Declared {
range: Range { start: 0, end: 0 },
},
};
if ax.graph.would_create_cycle(&proposed) {
return None;
}
Some((top_level_name, transitive_target.clone()))
}
fn collect_direct_groups(
ax: &AnalysisCtx<'_>,
plan: &FollowPlan,
) -> HashMap<String, Vec<(AttrPath, Option<String>)>> {
let mut groups: HashMap<String, Vec<(AttrPath, Option<String>)>> = HashMap::new();
for nested in ax.nested_inputs.iter() {
if !within_depth(&nested.path, ax.max_depth) {
continue;
}
if nested.follows.is_some() {
continue;
}
let nested_name = nested.path.last().as_str();
let path_display = nested.path.to_string();
if ax.follow_config.is_ignored(&path_display, nested_name) {
continue;
}
if ax.existing_follows.contains(&nested.path) || plan.seen_nested.contains(&nested.path) {
continue;
}
if ax
.top_level_inputs
.iter()
.any(|top| ax.follow_config.can_follow(nested_name, top))
{
continue;
}
let canonical_name = ax
.follow_config
.resolve_alias(nested_name)
.unwrap_or(nested_name)
.to_string();
if ax.top_level_inputs.contains(&canonical_name) {
continue;
}
groups
.entry(canonical_name)
.or_default()
.push((nested.path.clone(), nested.url.clone()));
}
groups
}
fn emit_transitive_promotions(
ax: &AnalysisCtx<'_>,
transitive_groups: HashMap<String, HashMap<AttrPath, Vec<AttrPath>>>,
plan: &mut FollowPlan,
) {
for (top_name, targets) in transitive_groups {
let mut eligible: Vec<(AttrPath, Vec<AttrPath>)> = targets
.into_iter()
.filter(|(_, paths)| paths.len() >= ax.transitive_min)
.collect();
if eligible.len() != 1 {
continue;
}
let (target_path, paths) = eligible.pop().unwrap();
if target_path.to_flake_follows_string() == top_name {
continue;
}
let top_seg = match Segment::from_unquoted(top_name.clone()) {
Ok(s) => s,
Err(e) => {
tracing::warn!(
"Skipping toplevel follow promotion for `{top_name}`: invalid segment: {e}"
);
continue;
}
};
plan.toplevel_follows
.push((AttrPath::new(top_seg.clone()), target_path));
let top_path = AttrPath::new(top_seg);
for path in paths {
if plan.seen_nested.insert(path.clone()) {
plan.to_follow.push((path, top_path.clone()));
}
}
}
}
fn emit_direct_promotions(
ax: &AnalysisCtx<'_>,
source_text: &str,
direct_groups: HashMap<String, Vec<(AttrPath, Option<String>)>>,
plan: &mut FollowPlan,
) {
let probe_parsed = validate::ParsedSource::new(source_text);
if !probe_parsed.parse_errors.is_empty() {
return;
}
let probe_syntax = probe_parsed.syntax;
let mut direct_groups_sorted: Vec<_> = direct_groups.into_iter().collect();
direct_groups_sorted.sort_by(|a, b| a.0.cmp(&b.0));
for (canonical_name, mut entries) in direct_groups_sorted {
entries.sort_by(|a, b| a.0.cmp(&b.0));
let Some((url, target_attr)) =
decide_direct_promotion(ax, &probe_syntax, &canonical_name, &entries)
else {
continue;
};
plan.toplevel_adds.push((canonical_name.clone(), url));
record_direct_promotion_entries(plan, &target_attr, &canonical_name, &entries);
}
}
fn decide_direct_promotion(
ax: &AnalysisCtx<'_>,
probe_syntax: &rnix::SyntaxNode,
canonical_name: &str,
entries: &[(AttrPath, Option<String>)],
) -> Option<(String, AttrPath)> {
if entries.len() < ax.transitive_min {
return None;
}
let url = entries.iter().find_map(|(_, u)| u.clone())?;
let target_attr = match Segment::from_unquoted(canonical_name.to_string()) {
Ok(seg) => AttrPath::new(seg),
Err(e) => {
tracing::warn!(
"Skipping direct-reference promotion for `{canonical_name}`: invalid input name: {e}"
);
return None;
}
};
let can_follow = entries.iter().any(|(path, _)| {
let change = Change::Follows {
input: ChangeId::new(path.clone()),
target: target_attr.clone(),
};
let mut fe = FlakeEdit::from_syntax(probe_syntax.clone());
fe.apply_change(change)
.ok()
.and_then(|outcome| outcome.text)
.is_some()
});
if !can_follow {
return None;
}
Some((url, target_attr))
}
fn record_direct_promotion_entries(
plan: &mut FollowPlan,
target_attr: &AttrPath,
canonical_name: &str,
entries: &[(AttrPath, Option<String>)],
) {
let entry_paths: HashSet<AttrPath> = entries.iter().map(|(p, _)| p.clone()).collect();
for (path, _) in entries {
let mut covered_by_ancestor = false;
let mut anc = path.parent();
while let Some(a) = anc.clone() {
if entry_paths.contains(&a) {
covered_by_ancestor = true;
break;
}
anc = a.parent();
}
if covered_by_ancestor {
tracing::debug!(
"Skipping promotion {} -> {}: ancestor in same group covers it",
path,
canonical_name,
);
plan.seen_nested.insert(path.clone());
continue;
}
if plan.seen_nested.insert(path.clone()) {
plan.to_follow.push((path.clone(), target_attr.clone()));
}
}
}
fn scrub_redundant(graph: &FollowsGraph, plan: &mut FollowPlan) {
if plan.to_follow.is_empty() {
return;
}
let mut keep: Vec<bool> = vec![true; plan.to_follow.len()];
let mut changed = true;
while changed {
changed = false;
for i in 0..plan.to_follow.len() {
if !keep[i] {
continue;
}
let (src_i, target_path) = (plan.to_follow[i].0.clone(), plan.to_follow[i].1.clone());
let extras: Vec<(AttrPath, AttrPath)> = (0..plan.to_follow.len())
.filter(|&j| j != i && keep[j])
.map(|j| (plan.to_follow[j].0.clone(), plan.to_follow[j].1.clone()))
.collect();
if graph.lock_routes_to(&src_i, &target_path, None, &extras) {
tracing::debug!(
"Scrubbing {} -> {}: redundant given the rest of the plan",
src_i,
target_path,
);
keep[i] = false;
changed = true;
}
}
}
let mut keep_iter = keep.into_iter();
plan.to_follow.retain(|_| keep_iter.next().unwrap_or(true));
}
struct PlanState {
current_text: String,
current_parsed: validate::ParsedSource,
warnings: Vec<validate::ValidationError>,
}
enum StepOutcome {
Accepted {
text_changed: bool,
},
Rejected(Vec<validate::ValidationError>),
NoText,
ApplyError(crate::error::Error),
}
impl PlanState {
fn try_apply_one(
&mut self,
change: Change,
lock_graph_ref: Option<&FollowsGraph>,
) -> StepOutcome {
let mut temp = FlakeEdit::from_syntax(self.current_parsed.syntax.clone());
let outcome = match temp.apply_change(change) {
Ok(o) => o,
Err(e) => return StepOutcome::ApplyError(e),
};
let resulting_text = match outcome.text {
Some(t) => t,
None => return StepOutcome::NoText,
};
let text_changed = resulting_text != self.current_text;
let resulting_parsed = validate::ParsedSource::new(&resulting_text);
let validation = validate::validate_speculative_parsed(
&resulting_parsed,
temp.curr_list(),
lock_graph_ref,
);
if validation.is_ok() {
self.warnings.extend(validation.warnings);
self.current_text = resulting_text;
self.current_parsed = resulting_parsed;
StepOutcome::Accepted { text_changed }
} else {
StepOutcome::Rejected(validation.errors)
}
}
}
fn apply_plan_text(
original_text: &str,
inputs: &crate::edit::InputMap,
nested_inputs: &[NestedInput],
lock_graph: &FollowsGraph,
plan: &FollowPlan,
) -> Result<AppliedPlan> {
let current_parsed = validate::ParsedSource::new(original_text);
if !current_parsed.parse_errors.is_empty() {
return Err(Error::Flake(crate::error::Error::Validation(
current_parsed.parse_errors.clone(),
)));
}
let mut warnings: Vec<validate::ValidationError> = Vec::new();
let pre_validation = validate::validate_full_with_lock_graph(
¤t_parsed,
inputs,
Some(lock_graph),
nested_inputs,
);
warnings.extend(pre_validation.warnings);
let lock_graph_ref = Some(lock_graph);
let mut state = PlanState {
current_text: original_text.to_owned(),
current_parsed,
warnings,
};
apply_toplevel_adds(plan, &mut state, lock_graph_ref);
let applied_follows = apply_follow_changes(plan, &mut state, lock_graph_ref);
let unfollowed = apply_unfollow_changes(plan, &mut state, lock_graph_ref);
Ok(AppliedPlan {
current_text: state.current_text,
applied_follows,
unfollowed,
warnings: state.warnings,
})
}
fn apply_toplevel_adds(
plan: &FollowPlan,
state: &mut PlanState,
lock_graph_ref: Option<&FollowsGraph>,
) {
for (id, url) in &plan.toplevel_adds {
let change_id = match ChangeId::parse(id) {
Ok(change_id) => change_id,
Err(e) => {
tracing::error!("could not add top-level input {id}: invalid id: {e}");
continue;
}
};
let change = Change::Add {
id: Some(change_id),
uri: Some(url.clone()),
flake: true,
};
match state.try_apply_one(change, lock_graph_ref) {
StepOutcome::Accepted { .. } => {}
StepOutcome::Rejected(errors) => {
for err in errors {
tracing::error!("could not add top-level input {id}: {err}");
}
}
StepOutcome::NoText => {
tracing::error!("could not add top-level input {id}");
}
StepOutcome::ApplyError(e) => {
tracing::error!("could not add top-level input {id}: {e}");
}
}
}
}
fn apply_follow_changes(
plan: &FollowPlan,
state: &mut PlanState,
lock_graph_ref: Option<&FollowsGraph>,
) -> Vec<(AttrPath, AttrPath)> {
let mut follow_changes: Vec<(AttrPath, AttrPath)> = plan.toplevel_follows.clone();
follow_changes.extend(plan.to_follow.iter().cloned());
let mut applied_follows: Vec<(AttrPath, AttrPath)> = Vec::new();
for (input_path, target) in &follow_changes {
let change = Change::Follows {
input: ChangeId::new(input_path.clone()),
target: target.clone(),
};
match state.try_apply_one(change, lock_graph_ref) {
StepOutcome::Accepted { text_changed: true } => {
applied_follows.push((input_path.clone(), target.clone()));
}
StepOutcome::Accepted {
text_changed: false,
} => {}
StepOutcome::Rejected(errors) => {
for err in errors {
tracing::error!("{}", format_apply_error(input_path, &err));
}
}
StepOutcome::NoText => {
tracing::error!("could not create follows for {input_path}");
}
StepOutcome::ApplyError(e) => {
tracing::error!("could not apply follows for {input_path}: {e}");
}
}
}
applied_follows
}
fn apply_unfollow_changes(
plan: &FollowPlan,
state: &mut PlanState,
lock_graph_ref: Option<&FollowsGraph>,
) -> Vec<AttrPath> {
let mut unfollowed: Vec<AttrPath> = Vec::new();
for nested_path in &plan.to_unfollow {
let change = Change::Remove {
ids: vec![ChangeId::new(nested_path.clone())],
};
match state.try_apply_one(change, lock_graph_ref) {
StepOutcome::Accepted { .. } => unfollowed.push(nested_path.clone()),
StepOutcome::Rejected(_) | StepOutcome::NoText => {}
StepOutcome::ApplyError(e) => {
tracing::error!("could not remove stale follows for {nested_path}: {e}");
}
}
}
unfollowed
}
fn render_summary(
editor: &Editor,
state: &AppState,
applied: &AppliedPlan,
quiet: bool,
) -> Result<()> {
if !applied.warnings.is_empty() && !quiet {
let mut seen: HashSet<String> = HashSet::new();
for warning in &applied.warnings {
if seen.insert(warning_dedup_key(warning)) {
eprintln!("warning: {}", warning);
}
}
}
if applied.current_text == editor.text() {
if !quiet {
println!("{SENTINEL_ALREADY_DEDUPLICATED}");
}
return Ok(());
}
if state.diff {
let original = editor.text();
let diff = crate::diff::Diff::new(&original, &applied.current_text);
diff.compare();
return Ok(());
}
editor.apply_or_diff(&applied.current_text, state)?;
if quiet {
return Ok(());
}
if !applied.applied_follows.is_empty() {
println!(
"Deduplicated {} {}.",
applied.applied_follows.len(),
if applied.applied_follows.len() == 1 {
"input"
} else {
"inputs"
}
);
for (input_path, target) in &applied.applied_follows {
println!(" {} -> {}", input_path, target);
}
}
if !applied.unfollowed.is_empty() {
println!(
"Removed {} stale follows {}.",
applied.unfollowed.len(),
if applied.unfollowed.len() == 1 {
"declaration"
} else {
"declarations"
}
);
for path in &applied.unfollowed {
println!(" {} (input no longer exists)", path);
}
}
Ok(())
}
fn offending_source(err: &validate::ValidationError) -> Option<&AttrPath> {
use validate::ValidationError as V;
match err {
V::FollowsTargetNotToplevel { edge, .. }
| V::FollowsStale { edge, .. }
| V::FollowsDepthExceeded { edge, .. } => Some(&edge.source),
V::FollowsContradiction { edges, .. } => edges.first().map(|e| &e.source),
V::FollowsCycle { cycle, .. } => cycle.edges.first().map(|e| &e.source),
V::FollowsStaleLock { source_path, .. } => Some(source_path),
V::ParseError { .. } | V::DuplicateAttribute(_) => None,
}
}
fn format_apply_error(applying: &AttrPath, err: &validate::ValidationError) -> String {
match offending_source(err) {
Some(source) if source != applying => {
format!(
"Malformed follows declaration in {}: {}",
source.first(),
err
)
}
_ => format!("Error applying follows for {}: {}", applying, err),
}
}
fn warning_dedup_key(err: &validate::ValidationError) -> String {
use validate::ValidationError as V;
match err {
V::FollowsStale { edge, .. } => format!(
"stale|{}|{}",
edge.source,
edge.follows.to_flake_follows_string()
),
V::FollowsStaleLock {
source_path,
declared_target,
lock_target,
..
} => {
let lock = lock_target
.as_ref()
.map(|t| t.to_flake_follows_string())
.unwrap_or_default();
format!(
"stale-lock|{source_path}|{}|{lock}",
declared_target.to_flake_follows_string()
)
}
other => format!("other|{other}"),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::FollowConfig;
use crate::input::{Follows, Input, Range};
use crate::validate::Location;
use clap::Parser;
fn declared_edge(source: &str, follows: &str) -> Edge {
Edge {
source: AttrPath::parse(source).expect("source"),
follows: AttrPath::parse(follows).expect("follows"),
origin: EdgeOrigin::Declared {
range: Range { start: 0, end: 0 },
},
}
}
fn loc() -> Location {
Location {
line: 60,
column: 13,
}
}
fn seg(s: &str) -> Segment {
Segment::from_unquoted(s).unwrap()
}
fn ap(s: &str) -> AttrPath {
AttrPath::parse(s).unwrap()
}
fn input_with_follows(id: &str, follows: Vec<(AttrPath, Option<AttrPath>)>) -> Input {
let mut input = Input::new(seg(id));
for (path, target) in follows {
input.follows.push(Follows::Indirect { path, target });
}
input.range = Range { start: 1, end: 2 };
input
}
fn make_input_map(items: Vec<Input>) -> InputMap {
let mut map = InputMap::new();
for item in items {
map.insert(item.id().as_str().to_string(), item);
}
map
}
fn parent_middle_lock() -> FlakeLock {
let lock_text = r#"{
"nodes": {
"top": {
"locked": { "lastModified": 1, "narHash": "", "owner": "o", "repo": "r", "rev": "a", "type": "github" },
"original": { "owner": "o", "repo": "r", "type": "github" }
},
"nixpkgs_2": {
"locked": { "lastModified": 1, "narHash": "", "owner": "o", "repo": "r", "rev": "b", "type": "github" },
"original": { "owner": "o", "repo": "r", "type": "github" }
},
"middle": {
"inputs": { "nixpkgs": "nixpkgs_2" },
"locked": { "lastModified": 1, "narHash": "", "owner": "o", "repo": "r", "rev": "c", "type": "github" },
"original": { "owner": "o", "repo": "r", "type": "github" }
},
"parent": {
"inputs": { "middle": "middle" },
"locked": { "lastModified": 1, "narHash": "", "owner": "o", "repo": "r", "rev": "d", "type": "github" },
"original": { "owner": "o", "repo": "r", "type": "github" }
},
"root": {
"inputs": { "top": "top", "parent": "parent" }
}
},
"root": "root",
"version": 7
}"#;
FlakeLock::read_from_str(lock_text).unwrap()
}
#[test]
fn seed_unfollow_set_empty_graph_returns_empty() {
let graph = FollowsGraph::default();
let result = seed_unfollow_set(&graph, Some(2));
assert_eq!(result, Vec::<AttrPath>::new());
}
#[test]
fn seed_unfollow_set_collects_stale_declared_edge() {
let inputs = make_input_map(vec![input_with_follows(
"home-manager",
vec![(ap("nixpkgs"), Some(ap("nixpkgs")))],
)]);
let graph = FollowsGraph::from_declared(&inputs);
let result = seed_unfollow_set(&graph, Some(2));
assert_eq!(result, vec![ap("home-manager.nixpkgs")]);
}
#[test]
fn seed_unfollow_set_collects_stale_nulled_source() {
let inputs = make_input_map(vec![input_with_follows(
"home-manager",
vec![(ap("nixpkgs"), None)],
)]);
let graph = FollowsGraph::from_declared(&inputs);
let result = seed_unfollow_set(&graph, Some(2));
assert_eq!(result, vec![ap("home-manager.nixpkgs")]);
}
#[test]
fn seed_unfollow_set_marks_redundant_depth_n_edge() {
let inputs = make_input_map(vec![input_with_follows(
"parent",
vec![
(ap("middle"), Some(ap("top"))),
(ap("middle.nixpkgs"), Some(ap("top.nixpkgs"))),
],
)]);
let graph = FollowsGraph::from_flake(&inputs, &parent_middle_lock());
let result = seed_unfollow_set(&graph, Some(2));
assert_eq!(result, vec![ap("parent.middle.nixpkgs")]);
}
#[test]
fn seed_unfollow_set_mixed_sources_are_sorted_and_deduped() {
let inputs = make_input_map(vec![
input_with_follows("home-manager", vec![(ap("nixpkgs"), None)]),
input_with_follows("nixos-cosmic", vec![(ap("nixpkgs"), Some(ap("nixpkgs")))]),
input_with_follows(
"parent",
vec![
(ap("middle"), Some(ap("top"))),
(ap("middle.nixpkgs"), Some(ap("top.nixpkgs"))),
],
),
]);
let graph = FollowsGraph::from_flake(&inputs, &parent_middle_lock());
let result = seed_unfollow_set(&graph, Some(2));
assert_eq!(
result,
vec![
ap("home-manager.nixpkgs"),
ap("nixos-cosmic.nixpkgs"),
ap("parent.middle.nixpkgs"),
],
);
}
#[test]
fn malformed_edge_blames_its_owner_not_iteration_target() {
let applying = AttrPath::parse("browservice.flake-utils").unwrap();
let err = validate::ValidationError::FollowsTargetNotToplevel {
edge: declared_edge(
"mac-app-util.cl-nix-lite",
r#""github:verymucho/cl-nix-lite""#,
),
location: loc(),
};
let message = format_apply_error(&applying, &err);
assert!(
!message.contains("browservice"),
"must not blame iteration target, got: {message}",
);
assert!(
message.contains("mac-app-util"),
"must name the malformed edge's owner, got: {message}",
);
}
#[test]
fn self_caused_error_keeps_apply_framing() {
let applying = AttrPath::parse("crane.nixpkgs").unwrap();
let err = validate::ValidationError::FollowsTargetNotToplevel {
edge: declared_edge("crane.nixpkgs", "missing"),
location: loc(),
};
let message = format_apply_error(&applying, &err);
assert!(
message.contains("Error applying follows for crane.nixpkgs"),
"self-caused error must keep apply framing, got: {message}",
);
}
#[test]
fn run_batch_surfaces_every_failure() {
let tmp = tempfile::tempdir().expect("tempdir");
let missing_a = tmp.path().join("a/flake.nix");
let missing_b = tmp.path().join("b/flake.nix");
let paths = vec![missing_a.clone(), missing_b.clone()];
let args = crate::cli::CliArgs::parse_from(["flake-edit", "follow"]);
let err = run_batch(&paths, None, None, &args).expect_err("expected batch failure");
let Error::Batch { failures } = err else {
panic!("expected Error::Batch, got: {err:?}");
};
assert_eq!(
failures.len(),
2,
"every per-file failure must reach the caller, got: {failures:?}",
);
for (path, err) in &failures {
assert!(
matches!(err.as_ref(), Error::FlakeNotFound { .. }),
"expected FlakeNotFound for missing flake.nix at {}, got {err:?}",
path.display(),
);
}
let collected: Vec<&std::path::PathBuf> = failures.iter().map(|(p, _)| p).collect();
assert!(collected.contains(&&missing_a));
assert!(collected.contains(&&missing_b));
}
#[test]
fn follow_walks_lockfile_once_per_invocation() {
let flake = r#"{
inputs = {
nixpkgs.url = "github:nixos/nixpkgs";
crane.url = "github:ipetkov/crane";
};
outputs = { ... }: { };
}
"#;
let lock = r#"{
"nodes": {
"nixpkgs": {
"locked": { "lastModified": 1, "narHash": "", "owner": "nixos", "repo": "nixpkgs", "rev": "aaa", "type": "github" },
"original": { "owner": "nixos", "repo": "nixpkgs", "type": "github" }
},
"nixpkgs_2": {
"locked": { "lastModified": 1, "narHash": "", "owner": "nixos", "repo": "nixpkgs", "rev": "bbb", "type": "github" },
"original": { "owner": "nixos", "repo": "nixpkgs", "type": "github" }
},
"crane": {
"inputs": { "nixpkgs": "nixpkgs_2" },
"locked": { "lastModified": 1, "narHash": "", "owner": "ipetkov", "repo": "crane", "rev": "ccc", "type": "github" },
"original": { "owner": "ipetkov", "repo": "crane", "type": "github" }
},
"root": {
"inputs": { "nixpkgs": "nixpkgs", "crane": "crane" }
}
},
"root": "root",
"version": 7
}"#;
crate::lock::NESTED_INPUTS_CALLS.with(|c| c.set(0));
let out = run_in_memory(flake, lock, &FollowConfig::default()).expect("run_in_memory");
let walks = crate::lock::NESTED_INPUTS_CALLS.with(|c| c.get());
assert!(
out.is_some(),
"fixture must produce a dedup so the apply path runs; got no change",
);
assert_eq!(
walks, 1,
"nested_inputs must be walked exactly once per follow invocation, got {walks}",
);
}
fn nested_input(path: &str, follows: Option<&str>, url: Option<&str>) -> NestedInput {
NestedInput {
path: ap(path),
follows: follows.map(ap),
url: url.map(ToOwned::to_owned),
}
}
struct CtxFixture<'a> {
inputs: InputMap,
graph: FollowsGraph,
existing_follows: HashSet<AttrPath>,
follow_config: FollowConfig,
nested_inputs: &'a [NestedInput],
top_level_inputs: HashSet<String>,
max_depth: Option<usize>,
transitive_min: usize,
}
impl<'a> CtxFixture<'a> {
fn new(nested_inputs: &'a [NestedInput], top_level: &[&str]) -> Self {
Self {
inputs: InputMap::new(),
graph: FollowsGraph::default(),
existing_follows: HashSet::new(),
follow_config: FollowConfig::default(),
nested_inputs,
top_level_inputs: top_level.iter().map(|s| (*s).to_string()).collect(),
max_depth: Some(1),
transitive_min: 2,
}
}
fn ctx(&self) -> AnalysisCtx<'_> {
AnalysisCtx {
nested_inputs: self.nested_inputs,
top_level_inputs: self.top_level_inputs.clone(),
inputs: &self.inputs,
graph: &self.graph,
existing_follows: &self.existing_follows,
follow_config: &self.follow_config,
max_depth: self.max_depth,
transitive_min: self.transitive_min,
}
}
}
#[test]
fn resolve_direct_candidate_returns_target_when_eligible() {
let nested = vec![nested_input("home-manager.nixpkgs", None, None)];
let mut fx = CtxFixture::new(&nested, &["nixpkgs", "home-manager"]);
fx.inputs = make_input_map(vec![Input::new(seg("nixpkgs"))]);
let result = resolve_direct_candidate(&fx.ctx(), &nested[0], &[]);
assert_eq!(result, Some(ap("nixpkgs")));
}
#[test]
fn resolve_direct_candidate_skips_when_no_top_level_match() {
let nested = vec![nested_input("home-manager.nixpkgs", None, None)];
let fx = CtxFixture::new(&nested, &["home-manager"]);
let result = resolve_direct_candidate(&fx.ctx(), &nested[0], &[]);
assert_eq!(result, None);
}
#[test]
fn resolve_transitive_candidate_returns_target_when_eligible() {
let nested = vec![nested_input("parent.foo", Some("top.bar"), None)];
let fx = CtxFixture::new(&nested, &["parent", "top"]);
let plan = FollowPlan::default();
let result = resolve_transitive_candidate(&fx.ctx(), &plan, &nested[0]);
assert_eq!(result, Some(("foo".to_string(), ap("top.bar"))));
}
#[test]
fn resolve_transitive_candidate_skips_self_follow() {
let nested = vec![nested_input("parent.nixpkgs", Some("other.nixpkgs"), None)];
let fx = CtxFixture::new(&nested, &["parent", "other"]);
let plan = FollowPlan::default();
let result = resolve_transitive_candidate(&fx.ctx(), &plan, &nested[0]);
assert_eq!(result, None);
}
#[test]
fn resolve_transitive_candidate_skips_when_already_seen() {
let nested = vec![nested_input(
"parent.flake-utils",
Some("top.flake-utils"),
None,
)];
let fx = CtxFixture::new(&nested, &["parent", "top"]);
let plan = FollowPlan {
seen_nested: std::iter::once(ap("parent.flake-utils")).collect(),
..FollowPlan::default()
};
let result = resolve_transitive_candidate(&fx.ctx(), &plan, &nested[0]);
assert_eq!(result, None);
}
#[test]
fn record_direct_promotion_entries_pushes_orphan_paths() {
let entries: Vec<(AttrPath, Option<String>)> = vec![
(ap("crane.flake-utils"), None),
(ap("treefmt.flake-utils"), None),
];
let target = ap("flake-utils");
let mut plan = FollowPlan::default();
record_direct_promotion_entries(&mut plan, &target, "flake-utils", &entries);
assert_eq!(
plan.to_follow,
vec![
(ap("crane.flake-utils"), ap("flake-utils")),
(ap("treefmt.flake-utils"), ap("flake-utils")),
],
);
assert!(plan.seen_nested.contains(&ap("crane.flake-utils")));
assert!(plan.seen_nested.contains(&ap("treefmt.flake-utils")));
}
#[test]
fn record_direct_promotion_entries_skips_descendants_covered_by_ancestor() {
let entries: Vec<(AttrPath, Option<String>)> = vec![
(ap("crane.flake-utils"), None),
(ap("crane.flake-utils.nested"), None),
];
let target = ap("flake-utils");
let mut plan = FollowPlan::default();
record_direct_promotion_entries(&mut plan, &target, "flake-utils", &entries);
assert_eq!(
plan.to_follow,
vec![(ap("crane.flake-utils"), ap("flake-utils"))],
);
assert!(
plan.seen_nested.contains(&ap("crane.flake-utils.nested")),
"ancestor-covered descendant must still claim seen_nested",
);
}
#[test]
fn resolve_direct_candidate_skips_existing_follow() {
let nested = vec![nested_input("home-manager.nixpkgs", None, None)];
let mut fx = CtxFixture::new(&nested, &["nixpkgs", "home-manager"]);
fx.inputs = make_input_map(vec![Input::new(seg("nixpkgs"))]);
fx.existing_follows = std::iter::once(ap("home-manager.nixpkgs")).collect();
let result = resolve_direct_candidate(&fx.ctx(), &nested[0], &[]);
assert_eq!(result, None);
}
fn fresh_state(text: &str) -> PlanState {
let parsed = validate::ParsedSource::new(text);
assert!(
parsed.parse_errors.is_empty(),
"test fixture must parse cleanly, got: {:?}",
parsed.parse_errors,
);
PlanState {
current_text: text.to_owned(),
current_parsed: parsed,
warnings: Vec::new(),
}
}
#[test]
fn apply_toplevel_adds_inserts_new_input() {
let original = r#"{
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs";
};
outputs = _: { };
}
"#;
let plan = FollowPlan {
toplevel_adds: vec![(
"flake-utils".to_string(),
"github:numtide/flake-utils".to_string(),
)],
..FollowPlan::default()
};
let mut state = fresh_state(original);
apply_toplevel_adds(&plan, &mut state, None);
assert!(
state
.current_text
.contains(r#"flake-utils.url = "github:numtide/flake-utils""#),
"added input declaration must appear verbatim, got:\n{}",
state.current_text,
);
}
#[test]
fn apply_follow_changes_records_accepted() {
let original = r#"{
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs";
home-manager.url = "github:nix-community/home-manager";
};
outputs = _: { };
}
"#;
let plan = FollowPlan {
to_follow: vec![(ap("home-manager.nixpkgs"), ap("nixpkgs"))],
..FollowPlan::default()
};
let mut state = fresh_state(original);
let applied = apply_follow_changes(&plan, &mut state, None);
assert_eq!(
applied,
vec![(ap("home-manager.nixpkgs"), ap("nixpkgs"))],
"happy-path follow must be recorded as applied",
);
assert!(
state
.current_text
.contains("home-manager.inputs.nixpkgs.follows = \"nixpkgs\""),
"follows declaration must be written, got:\n{}",
state.current_text,
);
}
#[test]
fn apply_follow_changes_skips_no_op_text() {
let original = r#"{
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs";
home-manager.url = "github:nix-community/home-manager";
home-manager.inputs.nixpkgs.follows = "nixpkgs";
};
outputs = _: { };
}
"#;
let plan = FollowPlan {
to_follow: vec![(ap("home-manager.nixpkgs"), ap("nixpkgs"))],
..FollowPlan::default()
};
let mut state = fresh_state(original);
let applied = apply_follow_changes(&plan, &mut state, None);
assert!(
applied.is_empty(),
"no-op follow must not be recorded, got: {applied:?}",
);
assert_eq!(
state.current_text, original,
"current_text must be byte-equal to original on no-op",
);
}
#[test]
fn apply_unfollow_changes_removes_stale() {
let original = r#"{
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs";
home-manager.url = "github:nix-community/home-manager";
home-manager.inputs.nixpkgs.follows = "nixpkgs";
};
outputs = _: { };
}
"#;
let plan = FollowPlan {
to_unfollow: vec![ap("home-manager.nixpkgs")],
..FollowPlan::default()
};
let mut state = fresh_state(original);
let unfollowed = apply_unfollow_changes(&plan, &mut state, None);
assert_eq!(
unfollowed,
vec![ap("home-manager.nixpkgs")],
"removed path must be reported",
);
assert!(
!state
.current_text
.contains("home-manager.inputs.nixpkgs.follows"),
"stale follows line must be gone, got:\n{}",
state.current_text,
);
}
#[test]
fn apply_unfollow_changes_skips_missing_path() {
let original = r#"{
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs";
};
outputs = _: { };
}
"#;
let plan = FollowPlan {
to_unfollow: vec![ap("does-not-exist")],
..FollowPlan::default()
};
let mut state = fresh_state(original);
let unfollowed = apply_unfollow_changes(&plan, &mut state, None);
assert!(
unfollowed.is_empty(),
"missing path must not be reported as removed, got: {unfollowed:?}",
);
assert_eq!(
state.current_text, original,
"current_text must be byte-equal when no path was removed",
);
}
#[test]
fn try_apply_one_leaves_state_on_no_text() {
let original = r#"{
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs";
};
outputs = _: { };
}
"#;
let mut state = fresh_state(original);
let change = Change::Remove {
ids: vec![ChangeId::new(ap("does-not-exist"))],
};
let outcome = state.try_apply_one(change, None);
assert!(
matches!(outcome, StepOutcome::NoText),
"expected NoText for a remove against an absent source",
);
assert_eq!(
state.current_text, original,
"state must be untouched on a non-Accepted outcome",
);
}
}