diff --git a/docs/ecosystems.md b/docs/ecosystems.md index 5eb8e3b..5880d45 100644 --- a/docs/ecosystems.md +++ b/docs/ecosystems.md @@ -11,12 +11,12 @@ | `python` | Python | implemented | | `go` | Go | implemented (per-project only) | | `flutter` | Flutter/Dart | implemented | +| `android` | Android | implemented | | `global` | Global Caches | implemented | | `llm` | LLM Model Stores | implemented | -| `android` | Android | planned | | `docker` | Docker | planned | -**Dedup attribution**: project ecosystems (node, rust, ruby, python, go, flutter) share a single-pass scan — a directory matching artifact rules of several active ecosystems is reported once, attributed to the first in scanner order (node → rust → ruby → python → go → flutter), so `--eco` subsets can shift attribution (a shared `coverage/` goes to node in a full scan, to ruby under `--eco ruby`). +**Dedup attribution**: project ecosystems (node, rust, ruby, python, go, flutter, android) share a single-pass scan — a directory matching artifact rules of several active ecosystems is reported once, attributed to the first in scanner order (node → rust → ruby → python → go → flutter → android), so `--eco` subsets can shift attribution (a shared `coverage/` goes to node in a full scan, to ruby under `--eco ruby`). ## Node.js @@ -132,6 +132,23 @@ - **The Flutter SDK checkout is excluded.** The SDK is itself a git repo full of `pubspec.yaml` roots, and its `engine/src/build` / `engine/src/flutter/build` are committed GN build-system *source* trees — not build output. Matching `build` by name would offer real SDK source for deletion, and gitignore-aware protection misses it (committed-clean files are not `protected`). The scanner detects the SDK root by its invariant bootstrap layout (`bin/flutter` + `bin/internal/engine.version`, location-independent — never a hardcoded path) and skips the whole subtree. - The global pub package cache `~/.pub-cache` (macOS/Linux default) is home-rooted and handled by the Global Caches scanner as `caution` — it is shared by every Flutter project and re-downloads on the next `flutter pub get`. The `PUB_CACHE` env override is not tracked; only the default location is scanned. +## Android + +**Detection**: `build.gradle` or `build.gradle.kts` in parent directory. Every Gradle module carries its own build script, so the root project and each subproject (`app/`, `feature/`, ...) is detected as its own project root. + +**Artifacts**: + +| Pattern | Category | Safety | Description | +|---------|----------|--------|-------------| +| `build` | build | safe | Compiled output (what `gradle clean` removes) | +| `.gradle` | cache | safe | Per-project Gradle cache (regenerates on next build) | + +**Notes**: +- Because each module has its own marker, the single `build` rule reclaims every module's output (`build`, `app/build`, `feature/build`, ...) without enumerating module names. +- **Scope is per-project only.** The shared Gradle user home (`~/.gradle/caches`), AVD images, and NDK / system-images are home-rooted and handled by the Global Caches scanner, not here — no duplicate registration. +- **No SDK exclusion needed** (unlike Flutter): the Android SDK ships no `build.gradle`, so it never establishes a project context and nothing inside it is offered for deletion. +- **React Native overlap**: an RN project nests an `android/` Gradle tree. `android/build` and `android/.gradle` are matched by node's RN rules first in scanner order, so they attribute to node; the android scanner still covers the deeper module builds (`android/app/build`) the RN rules do not list. + ## Xcode (macOS only) **Detection**: fixed paths under `~/Library/Developer/...` and `~/Library/Logs/...`. The scanner does not walk a project tree — it checks a known set of Xcode/CoreSimulator directories and reports the ones that exist. On non-darwin platforms, the scanner is a no-op. diff --git a/internal/scanner/android.go b/internal/scanner/android.go new file mode 100644 index 0000000..0d7dcda --- /dev/null +++ b/internal/scanner/android.go @@ -0,0 +1,30 @@ +package scanner + +import "github.com/ohing504/devclean/internal/model" + +// androidWalkEcosystem drives Android/Gradle scanning in the single-pass walk +// engine. Every Gradle module carries its own build.gradle(.kts), so the root +// project and each subproject (app/, feature/, ...) is detected as its own +// project root; the single `build` rule then reclaims every module's output +// without enumerating module names. `.gradle` is the per-project Gradle cache, +// regenerated on the next build. +// +// Scope is per-project only. The shared Gradle user home (~/.gradle/caches), +// AVD images, and NDK/system-images are home-rooted and belong to the Global +// Caches scanner's catalog, not here. +// +// No SDK PruneRoot is needed (unlike Flutter): the Android SDK ships no +// build.gradle, so it never establishes a project context and nothing inside it +// is ever matched. React Native projects nest an android/ Gradle tree, but +// node's RN rules match android/build first in table order, so those artifacts +// attribute to node; this scanner still covers the deeper module builds +// (android/app/build) that the RN rules do not list. +var androidWalkEcosystem = walkEcosystem{ + Name: "android", + Eco: model.EcoAndroid, + Markers: []string{"build.gradle", "build.gradle.kts"}, + Rules: []artifactRule{ + {RelPath: "build", Category: model.CatBuild, Safety: model.SafetySafe}, // compiled output (gradle clean) + {RelPath: ".gradle", Category: model.CatCache, Safety: model.SafetySafe}, // per-project Gradle cache + }, +} diff --git a/internal/scanner/android_test.go b/internal/scanner/android_test.go new file mode 100644 index 0000000..74a78e6 --- /dev/null +++ b/internal/scanner/android_test.go @@ -0,0 +1,150 @@ +package scanner_test + +import ( + "context" + "path/filepath" + "testing" + + "github.com/ohing504/devclean/internal/model" + "github.com/ohing504/devclean/internal/scanner" +) + +func TestAndroidScanner_FindsBuildAndGradle(t *testing.T) { + root := t.TempDir() + + projDir := filepath.Join(root, "myapp") + mustMkdir(t, filepath.Join(projDir, "build", "outputs")) + mustWriteFile(t, filepath.Join(projDir, "build", "outputs", "app.apk"), make([]byte, 8192)) + mustMkdir(t, filepath.Join(projDir, ".gradle")) + mustWriteFile(t, filepath.Join(projDir, ".gradle", "state"), make([]byte, 1024)) + mustWriteFile(t, filepath.Join(projDir, "build.gradle.kts"), []byte("plugins {}")) + + results, err := scanner.WalkScan(context.Background(), root, model.EcoAndroid) + if err != nil { + t.Fatalf("Scan error: %v", err) + } + + if len(results) != 2 { + t.Fatalf("expected 2 results (build + .gradle), got %d", len(results)) + } + for _, r := range results { + if r.Ecosystem != model.EcoAndroid { + t.Errorf("expected ecosystem=android, got %s", r.Ecosystem) + } + if r.Safety != model.SafetySafe { + t.Errorf("expected safety=safe, got %s", r.Safety) + } + } +} + +func TestAndroidScanner_IgnoresWithoutGradleMarker(t *testing.T) { + root := t.TempDir() + + // build/ + .gradle/ without a build.gradle marker should be ignored. + mustMkdir(t, filepath.Join(root, "random", "build")) + mustMkdir(t, filepath.Join(root, "random", ".gradle")) + + results, err := scanner.WalkScan(context.Background(), root, model.EcoAndroid) + if err != nil { + t.Fatalf("Scan error: %v", err) + } + if len(results) != 0 { + t.Errorf("expected 0 results without a gradle marker, got %d", len(results)) + } +} + +// TestAndroidScanner_MultiModule pins the core design: each module carries its +// own build.gradle and is detected as its own project root, so a single `build` +// rule reclaims every module's output without enumerating module names. +func TestAndroidScanner_MultiModule(t *testing.T) { + root := t.TempDir() + + // Root project with settings + build script, plus two subproject modules. + mustWriteFile(t, filepath.Join(root, "build.gradle"), []byte("// root")) + mustMkdir(t, filepath.Join(root, "build")) + mustWriteFile(t, filepath.Join(root, "build", "out"), make([]byte, 4096)) + + for _, mod := range []string{"app", "feature"} { + modDir := filepath.Join(root, mod) + mustMkdir(t, filepath.Join(modDir, "build")) + mustWriteFile(t, filepath.Join(modDir, "build.gradle"), []byte("// "+mod)) + mustWriteFile(t, filepath.Join(modDir, "build", "out"), make([]byte, 4096)) + } + + results, err := scanner.WalkScan(context.Background(), root, model.EcoAndroid) + if err != nil { + t.Fatalf("Scan error: %v", err) + } + + want := map[string]bool{ + filepath.Join(root, "build"): false, + filepath.Join(root, "app", "build"): false, + filepath.Join(root, "feature", "build"): false, + } + for _, r := range results { + if _, ok := want[r.Path]; ok { + want[r.Path] = true + } + } + for path, found := range want { + if !found { + t.Errorf("expected module build/ to be reported: %s (results=%v)", path, results) + } + } +} + +// TestAndroidScanner_ReactNativeAttributedToNode pins that a React Native +// project's android/build is attributed to node (first in table order), not +// duplicated by the android scanner, while the android scanner still covers the +// deeper module build the RN rules do not list. +func TestAndroidScanner_ReactNativeAttributedToNode(t *testing.T) { + root := t.TempDir() + + rn := filepath.Join(root, "rnapp") + mustMkdir(t, rn) + mustWriteFile(t, filepath.Join(rn, "package.json"), []byte("{}")) + mustWriteFile(t, filepath.Join(rn, "metro.config.js"), []byte("module.exports = {}")) + + androidDir := filepath.Join(rn, "android") + mustMkdir(t, filepath.Join(androidDir, "build")) + mustWriteFile(t, filepath.Join(androidDir, "build.gradle"), []byte("// android")) + mustWriteFile(t, filepath.Join(androidDir, "build", "out"), make([]byte, 4096)) + + appDir := filepath.Join(androidDir, "app") + mustMkdir(t, filepath.Join(appDir, "build")) + mustWriteFile(t, filepath.Join(appDir, "build.gradle"), []byte("// app")) + mustWriteFile(t, filepath.Join(appDir, "build", "out"), make([]byte, 4096)) + + results, err := scanner.WalkScan(context.Background(), root, model.EcoNode, model.EcoAndroid) + if err != nil { + t.Fatalf("Scan error: %v", err) + } + + byPath := make(map[string]model.Ecosystem, len(results)) + for _, r := range results { + if prev, dup := byPath[r.Path]; dup { + t.Errorf("path reported twice: %s (%s and %s)", r.Path, prev, r.Ecosystem) + } + byPath[r.Path] = r.Ecosystem + } + + if got := byPath[filepath.Join(androidDir, "build")]; got != model.EcoNode { + t.Errorf("android/build should attribute to node, got %q", got) + } + if got := byPath[filepath.Join(appDir, "build")]; got != model.EcoAndroid { + t.Errorf("android/app/build should attribute to android, got %q", got) + } +} + +func TestAndroidScanner_NameAndEcosystem(t *testing.T) { + for _, s := range scanner.DefaultRegistry().All() { + if s.Name() != "android" { + continue + } + if s.Ecosystem() != model.EcoAndroid { + t.Errorf("expected ecosystem=android, got %s", s.Ecosystem()) + } + return + } + t.Error(`expected a registered scanner named "android"`) +} diff --git a/internal/scanner/registry_default.go b/internal/scanner/registry_default.go index e952258..f3296f4 100644 --- a/internal/scanner/registry_default.go +++ b/internal/scanner/registry_default.go @@ -9,6 +9,7 @@ func DefaultRegistry() *Registry { reg.Register(newWalkScanner(pythonWalkEcosystem)) reg.Register(newWalkScanner(goWalkEcosystem)) reg.Register(newWalkScanner(flutterWalkEcosystem)) + reg.Register(newWalkScanner(androidWalkEcosystem)) reg.Register(NewXcodeScanner()) reg.Register(NewGlobalScanner()) reg.Register(NewLLMScanner()) diff --git a/internal/scanner/walk.go b/internal/scanner/walk.go index d18cafc..e69b6fd 100644 --- a/internal/scanner/walk.go +++ b/internal/scanner/walk.go @@ -74,7 +74,7 @@ type walkEcosystem struct { // ecosystems. The order decides attribution when a directory matches rules // of several ecosystems (first match wins) and the result ordering; it // mirrors the registry order. -var walkEcosystemTable = []walkEcosystem{nodeWalkEcosystem, rustWalkEcosystem, rubyWalkEcosystem, pythonWalkEcosystem, goWalkEcosystem, flutterWalkEcosystem} +var walkEcosystemTable = []walkEcosystem{nodeWalkEcosystem, rustWalkEcosystem, rubyWalkEcosystem, pythonWalkEcosystem, goWalkEcosystem, flutterWalkEcosystem, androidWalkEcosystem} // WalkScan runs the single-pass walk engine over root with the tables of the // given ecosystems activated. Ecosystems without a walk table are ignored.