---
phase: 03
plan: 02
title: StorageService — CSOM StorageMetrics Scan Engine
status: pending
wave: 1
depends_on:
  - 03-01
files_modified:
  - SharepointToolbox/Services/StorageService.cs
autonomous: true
requirements:
  - STOR-01
  - STOR-02
  - STOR-03

must_haves:
  truths:
    - "StorageService implements IStorageService and is registered in DI (added in Plan 03-07)"
    - "CollectStorageAsync returns one StorageNode per document library at IndentLevel=0, with correct TotalSizeBytes, FileStreamSizeBytes, VersionSizeBytes, TotalFileCount, and LastModified"
    - "With FolderDepth>0, child StorageNodes are recursively populated and appear at IndentLevel=1+"
    - "VersionSizeBytes = TotalSizeBytes - FileStreamSizeBytes (never negative)"
    - "All CSOM round-trips use ExecuteQueryRetryHelper.ExecuteQueryRetryAsync — no direct ctx.ExecuteQueryAsync calls"
    - "System/hidden lists are skipped (Hidden=true or BaseType != DocumentLibrary)"
    - "ct.ThrowIfCancellationRequested() is called at the top of every recursive step"
  artifacts:
    - path: "SharepointToolbox/Services/StorageService.cs"
      provides: "CSOM scan engine — IStorageService implementation"
      exports: ["StorageService"]
  key_links:
    - from: "StorageService.cs"
      to: "ExecuteQueryRetryHelper.ExecuteQueryRetryAsync"
      via: "every CSOM load"
      pattern: "ExecuteQueryRetryHelper\\.ExecuteQueryRetryAsync"
    - from: "StorageService.cs"
      to: "folder.StorageMetrics"
      via: "ctx.Load include expression"
      pattern: "StorageMetrics"
---

# Plan 03-02: StorageService — CSOM StorageMetrics Scan Engine

## Goal

Implement `StorageService` — the C# port of the PowerShell `Get-PnPFolderStorageMetric` / `Collect-FolderStorage` pattern. It loads `Folder.StorageMetrics` for each document library on a site (and optionally recurses into subfolders up to a configurable depth), returning a flat list of `StorageNode` objects that the ViewModel will display in a `DataGrid`.

## Context

Plan 03-01 created `StorageNode`, `StorageScanOptions`, and `IStorageService`. This plan creates the only concrete implementation. The service receives an already-authenticated `ClientContext` from the ViewModel (obtained via `ISessionManager.GetOrCreateContextAsync`) — it never calls SessionManager itself.

Critical loading pattern: `ctx.Load(folder, f => f.StorageMetrics, f => f.TimeLastModified, f => f.Name, f => f.ServerRelativeUrl)` — if `StorageMetrics` is not in the Load expression, `folder.StorageMetrics.TotalSize` throws `PropertyOrFieldNotInitializedException`.

The `VersionSizeBytes` derived property is already on `StorageNode` (`TotalSizeBytes - FileStreamSizeBytes`). StorageService only needs to populate `TotalSizeBytes` and `FileStreamSizeBytes`.

## Tasks

### Task 1: Implement StorageService

**File:** `SharepointToolbox/Services/StorageService.cs`

**Action:** Create

**Why:** Implements STOR-01, STOR-02, STOR-03. Single file, single concern — no helper changes needed.

```csharp
using Microsoft.SharePoint.Client;
using SharepointToolbox.Core.Helpers;
using SharepointToolbox.Core.Models;

namespace SharepointToolbox.Services;

/// <summary>
/// CSOM-based storage metrics scanner.
/// Port of PowerShell Collect-FolderStorage / Get-PnPFolderStorageMetric pattern.
/// </summary>
public class StorageService : IStorageService
{
    public async Task<IReadOnlyList<StorageNode>> CollectStorageAsync(
        ClientContext ctx,
        StorageScanOptions options,
        IProgress<OperationProgress> progress,
        CancellationToken ct)
    {
        ct.ThrowIfCancellationRequested();

        // Load web-level metadata in one round-trip
        ctx.Load(ctx.Web,
            w => w.Title,
            w => w.Url,
            w => w.ServerRelativeUrl,
            w => w.Lists.Include(
                l => l.Title,
                l => l.Hidden,
                l => l.BaseType,
                l => l.RootFolder.ServerRelativeUrl));
        await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);

        string webSrl = ctx.Web.ServerRelativeUrl.TrimEnd('/');
        string siteTitle = ctx.Web.Title;

        var result = new List<StorageNode>();
        var libs = ctx.Web.Lists
            .Where(l => !l.Hidden && l.BaseType == BaseType.DocumentLibrary)
            .ToList();

        int idx = 0;
        foreach (var lib in libs)
        {
            ct.ThrowIfCancellationRequested();
            idx++;
            progress.Report(new OperationProgress(idx, libs.Count,
                $"Loading storage metrics: {lib.Title} ({idx}/{libs.Count})"));

            var libNode = await LoadFolderNodeAsync(
                ctx, lib.RootFolder.ServerRelativeUrl, lib.Title,
                siteTitle, lib.Title, 0, progress, ct);

            if (options.FolderDepth > 0)
            {
                await CollectSubfoldersAsync(
                    ctx, lib.RootFolder.ServerRelativeUrl,
                    libNode, 1, options.FolderDepth,
                    siteTitle, lib.Title, progress, ct);
            }

            result.Add(libNode);
        }

        return result;
    }

    // ── Private helpers ──────────────────────────────────────────────────────

    private static async Task<StorageNode> LoadFolderNodeAsync(
        ClientContext ctx,
        string serverRelativeUrl,
        string name,
        string siteTitle,
        string library,
        int indentLevel,
        IProgress<OperationProgress> progress,
        CancellationToken ct)
    {
        ct.ThrowIfCancellationRequested();

        Folder folder = ctx.Web.GetFolderByServerRelativeUrl(serverRelativeUrl);
        ctx.Load(folder,
            f => f.StorageMetrics,
            f => f.TimeLastModified,
            f => f.ServerRelativeUrl,
            f => f.Name);
        await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);

        DateTime? lastMod = folder.StorageMetrics.LastModified > DateTime.MinValue
            ? folder.StorageMetrics.LastModified
            : folder.TimeLastModified > DateTime.MinValue
                ? folder.TimeLastModified
                : (DateTime?)null;

        return new StorageNode
        {
            Name               = name,
            Url                = ctx.Url.TrimEnd('/') + serverRelativeUrl,
            SiteTitle          = siteTitle,
            Library            = library,
            TotalSizeBytes     = folder.StorageMetrics.TotalSize,
            FileStreamSizeBytes = folder.StorageMetrics.TotalFileStreamSize,
            TotalFileCount     = folder.StorageMetrics.TotalFileCount,
            LastModified       = lastMod,
            IndentLevel        = indentLevel,
            Children           = new List<StorageNode>()
        };
    }

    private static async Task CollectSubfoldersAsync(
        ClientContext ctx,
        string parentServerRelativeUrl,
        StorageNode parentNode,
        int currentDepth,
        int maxDepth,
        string siteTitle,
        string library,
        IProgress<OperationProgress> progress,
        CancellationToken ct)
    {
        if (currentDepth > maxDepth) return;
        ct.ThrowIfCancellationRequested();

        // Load direct child folders of this folder
        Folder parentFolder = ctx.Web.GetFolderByServerRelativeUrl(parentServerRelativeUrl);
        ctx.Load(parentFolder,
            f => f.Folders.Include(
                sf => sf.Name,
                sf => sf.ServerRelativeUrl));
        await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);

        foreach (Folder subFolder in parentFolder.Folders)
        {
            ct.ThrowIfCancellationRequested();

            // Skip SharePoint system folders
            if (subFolder.Name.Equals("Forms", StringComparison.OrdinalIgnoreCase) ||
                subFolder.Name.StartsWith("_", StringComparison.Ordinal))
                continue;

            var childNode = await LoadFolderNodeAsync(
                ctx, subFolder.ServerRelativeUrl, subFolder.Name,
                siteTitle, library, currentDepth, progress, ct);

            if (currentDepth < maxDepth)
            {
                await CollectSubfoldersAsync(
                    ctx, subFolder.ServerRelativeUrl, childNode,
                    currentDepth + 1, maxDepth,
                    siteTitle, library, progress, ct);
            }

            parentNode.Children.Add(childNode);
        }
    }
}
```

**Verification:**

```bash
dotnet build C:/Users/dev/Documents/projets/Sharepoint/SharepointToolbox.slnx
dotnet test C:/Users/dev/Documents/projets/Sharepoint/SharepointToolbox.Tests/SharepointToolbox.Tests.csproj --filter "FullyQualifiedName~StorageServiceTests" -x
```

Expected: 0 build errors; 2 pure-logic tests pass (VersionSizeBytes), 2 CSOM stubs skip

## Verification

```bash
dotnet build C:/Users/dev/Documents/projets/Sharepoint/SharepointToolbox.slnx
```

Expected: 0 errors. `StorageService` implements `IStorageService` (grep: `class StorageService : IStorageService`). `ExecuteQueryRetryHelper.ExecuteQueryRetryAsync` is called for every folder load (grep verifiable).

## Commit Message
feat(03-02): implement StorageService CSOM StorageMetrics scan engine

## Output

After completion, create `.planning/phases/03-storage/03-02-SUMMARY.md`
