Skip to content

Commit e871e0d

Browse files
Copilotsourcefrog
andauthored
Add struct field deletion mutations for literals with base expressions (#558)
- [x] Add new Genre variant for struct field deletion mutations - [x] Implement visit_expr_struct method to detect struct literals with base (default) expressions - [x] Generate mutants that delete individual fields from struct literals with defaults - [x] Add unit tests in visit.rs to verify struct field deletion - [x] Update documentation in book and NEWS.md - [x] Fix trailing comma handling to generate valid Rust code - [x] Include struct type and function name in mutant descriptions - [x] Refactor to use MutationTarget enum instead of string parsing Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Martin Pool <mbp@sourcefrog.net>
1 parent 91c7d76 commit e871e0d

File tree

4 files changed

+209
-1
lines changed

4 files changed

+209
-1
lines changed

NEWS.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@
1414

1515
- New: `start_time` and `end_time` fields in `outcomes.json`.
1616

17+
- New: Delete individual fields from struct literals that have a base (default) expression like `..Default::default()` or `..base_value`. This checks that tests verify each field is set correctly and not just relying on default values.
18+
1719
- New: `cargo_mutants_version` field in `outcomes.json`.
1820

1921
- Changed: Functions with attributes whose path ends with `test` are now skipped, not just those with the plain `#[test]` attribute. This means functions with `#[tokio::test]`, `#[sqlx::test]`, and similar testing framework attributes are automatically excluded from mutation testing.

book/src/mutants.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,3 +119,25 @@ Match expressions without a wildcard pattern would be too prone to unviable muta
119119
## Match arm guards
120120

121121
Match arm guard expressions are replaced with `true` and `false`.
122+
123+
## Struct literal fields
124+
125+
Individual fields are deleted from struct literals that have a base (default) expression,
126+
such as `..Default::default()` or `..base_value`.
127+
128+
For example, in this code:
129+
130+
```rust
131+
let cat = Cat {
132+
name: "Felix",
133+
coat: Coat::Tuxedo,
134+
..Default::default()
135+
};
136+
```
137+
138+
cargo-mutants will generate two mutants: one deleting the `name` field and one deleting
139+
the `coat` field. This checks that tests verify that each field is set correctly and not
140+
just relying on the default values.
141+
142+
Struct literals without a base expression are not mutated in this way, because deleting
143+
a required field would make the code fail to compile.

src/mutant.rs

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,20 @@ pub enum Genre {
3131
MatchArm,
3232
/// Replace the expression of a match arm guard with a fixed value.
3333
MatchArmGuard,
34+
/// Delete a field from a struct literal that has a base (default) expression.
35+
StructField,
36+
}
37+
38+
/// The target of a mutation, providing additional context about what is being mutated.
39+
#[derive(Clone, Eq, PartialEq, Debug)]
40+
pub enum MutationTarget {
41+
/// A field in a struct literal expression.
42+
StructLiteralField {
43+
/// The name of the field being deleted.
44+
field_name: String,
45+
/// The name/type of the struct.
46+
struct_name: String,
47+
},
3448
}
3549

3650
/// A mutation applied to source code.
@@ -63,6 +77,12 @@ pub struct Mutant {
6377

6478
/// What general category of mutant this is.
6579
pub genre: Genre,
80+
81+
/// Additional context about what is being mutated.
82+
///
83+
/// This provides structured information about the mutation target, rather than
84+
/// encoding it in strings that need to be parsed.
85+
pub target: Option<MutationTarget>,
6686
}
6787

6888
/// The function containing a mutant.
@@ -177,6 +197,22 @@ impl Mutant {
177197
.yellow(),
178198
);
179199
}
200+
Genre::StructField => {
201+
if let Some(MutationTarget::StructLiteralField {
202+
field_name,
203+
struct_name,
204+
}) = &self.target
205+
{
206+
v.push(s("delete field "));
207+
v.push(s(field_name).yellow());
208+
v.push(s(" from struct "));
209+
v.push(s(struct_name).yellow());
210+
v.push(s(" expression"));
211+
} else {
212+
// Fallback: shouldn't happen with proper initialization
213+
v.push(s("delete field from struct expression"));
214+
}
215+
}
180216
_ => {
181217
if self.replacement.is_empty() {
182218
v.push(s("delete "));

src/visit.rs

Lines changed: 149 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ use tracing::{debug_span, error, info, trace, trace_span, warn};
2424

2525
use crate::console::WalkProgress;
2626
use crate::fnvalue::return_type_replacements;
27-
use crate::mutant::Function;
27+
use crate::mutant::{Function, MutationTarget};
2828
use crate::package::Package;
2929
use crate::pretty::ToPrettyString;
3030
use crate::source::SourceFile;
@@ -324,6 +324,7 @@ impl DiscoveryVisitor<'_> {
324324
short_replaced: None,
325325
replacement: replacement.to_pretty_string(),
326326
genre,
327+
target: None,
327328
});
328329
}
329330

@@ -661,6 +662,7 @@ impl<'ast> Visit<'ast> for DiscoveryVisitor<'_> {
661662
short_replaced,
662663
replacement: String::new(),
663664
genre: Genre::MatchArm,
665+
target: None,
664666
};
665667
self.mutants.push(mutant);
666668
}
@@ -686,6 +688,58 @@ impl<'ast> Visit<'ast> for DiscoveryVisitor<'_> {
686688

687689
syn::visit::visit_expr_match(self, i);
688690
}
691+
692+
fn visit_expr_struct(&mut self, i: &'ast syn::ExprStruct) {
693+
let _span = trace_span!("struct", line = i.span().start().line).entered();
694+
trace!("visit struct expression");
695+
696+
if attrs_excluded(&i.attrs) {
697+
return;
698+
}
699+
700+
// Check if this struct has a base (default) expression like `..Default::default()`
701+
if let Some(_rest) = &i.rest {
702+
// Get the struct type name
703+
let struct_name = i.path.to_pretty_string();
704+
705+
// Generate a mutant for each field by deleting it
706+
// We need to include the trailing comma in the span
707+
for pair in i.fields.pairs() {
708+
let field = pair.value();
709+
if let syn::Member::Named(field_name) = &field.member {
710+
let field_name_str = field_name.to_string();
711+
// Span includes the field and its trailing comma (if present)
712+
let span = if let Some(comma) = pair.punct() {
713+
// Include the comma in the span
714+
let field_span = field.span();
715+
let comma_span = comma.span();
716+
Span {
717+
start: field_span.start().into(),
718+
end: comma_span.end().into(),
719+
}
720+
} else {
721+
// No comma, just the field
722+
field.span().into()
723+
};
724+
let mutant = Mutant {
725+
source_file: self.source_file.clone(),
726+
function: self.fn_stack.last().cloned(),
727+
span,
728+
short_replaced: None,
729+
replacement: String::new(),
730+
genre: Genre::StructField,
731+
target: Some(MutationTarget::StructLiteralField {
732+
field_name: field_name_str,
733+
struct_name: struct_name.clone(),
734+
}),
735+
};
736+
self.mutants.push(mutant);
737+
}
738+
}
739+
}
740+
741+
syn::visit::visit_expr_struct(self, i);
742+
}
689743
}
690744

691745
// Get the span of the block excluding the braces, or None if it is empty.
@@ -1494,4 +1548,98 @@ mod test {
14941548
assert_eq!(mutate_expr("a >>= b"), &["replace >>= with <<="]);
14951549
assert_eq!(mutate_expr("a <<= b"), &["replace <<= with >>="]);
14961550
}
1551+
1552+
#[test]
1553+
fn delete_struct_field_with_default() {
1554+
let mutants = mutate_expr(
1555+
r#"
1556+
let cat = Cat {
1557+
name: "Felix",
1558+
coat: Coat::Tuxedo,
1559+
..Default::default()
1560+
};
1561+
"#,
1562+
);
1563+
// Should generate mutants to delete each field
1564+
assert_eq!(
1565+
mutants,
1566+
&[
1567+
"delete field name from struct Cat expression",
1568+
"delete field coat from struct Cat expression"
1569+
]
1570+
);
1571+
}
1572+
1573+
#[test]
1574+
fn skip_struct_without_default() {
1575+
let mutants = mutate_expr(
1576+
r#"
1577+
let cat = Cat {
1578+
name: "Felix",
1579+
coat: Coat::Tuxedo,
1580+
};
1581+
"#,
1582+
);
1583+
// Should not generate any field deletion mutants without a default
1584+
assert!(mutants.is_empty());
1585+
}
1586+
1587+
#[test]
1588+
fn delete_struct_field_with_custom_default() {
1589+
let mutants = mutate_expr(
1590+
r#"
1591+
let config = Config {
1592+
timeout: 30,
1593+
retries: 5,
1594+
..base_config
1595+
};
1596+
"#,
1597+
);
1598+
// Should work with any base expression, not just Default::default()
1599+
assert_eq!(
1600+
mutants,
1601+
&[
1602+
"delete field timeout from struct Config expression",
1603+
"delete field retries from struct Config expression"
1604+
]
1605+
);
1606+
}
1607+
1608+
#[test]
1609+
fn delete_struct_field_single_field_with_default() {
1610+
let mutants = mutate_expr(
1611+
r#"
1612+
let point = Point {
1613+
x: 10,
1614+
..Default::default()
1615+
};
1616+
"#,
1617+
);
1618+
// Should work with a single field too
1619+
assert_eq!(mutants, &["delete field x from struct Point expression"]);
1620+
}
1621+
1622+
#[test]
1623+
fn delete_struct_field_complex_values() {
1624+
let mutants = mutate_expr(
1625+
r#"
1626+
let settings = Settings {
1627+
enabled: get_enabled(),
1628+
count: compute_count() + 10,
1629+
..Settings::default()
1630+
};
1631+
"#,
1632+
);
1633+
// Should work regardless of the complexity of the field values
1634+
// The visitor also generates mutants for expressions within field values
1635+
assert_eq!(
1636+
mutants,
1637+
&[
1638+
"delete field enabled from struct Settings expression",
1639+
"delete field count from struct Settings expression",
1640+
"replace + with -",
1641+
"replace + with *"
1642+
]
1643+
);
1644+
}
14971645
}

0 commit comments

Comments
 (0)