> ## Documentation Index
> Fetch the complete documentation index at: https://docs.formae.io/llms.txt
> Use this file to discover all available pages before exploring further.

# 09 - List

> Implement the List method to enable discovery of unmanaged resources, with pagination and non-discoverable types.

This section implements the `List` method. List enables **resource discovery**: finding resources that exist in the infrastructure but aren't yet managed by formae.

## What is discovery?

Discovery allows formae to find "unmanaged" resources: resources that exist in your infrastructure but weren't created through formae. This is useful for:

* **Importing existing infrastructure**: bring pre-existing resources under formae management
* **Detecting out-of-band resources**: find resources created outside of formae
* **Inventory auditing**: get a complete view of what exists

The agent periodically runs discovery to scan for new resources. When it finds unmanaged resources, it can present them to the user for import.

```
┌─────────┐         ┌────────┐         ┌──────────────┐
│  Agent  │         │ Plugin │         │ Infrastructure│
└────┬────┘         └───┬────┘         └──────┬───────┘
     │                  │                     │
     │  List(File)      │                     │
     │─────────────────>│  List files         │
     │                  │────────────────────>│
     │  [file1, file2,  │  [file paths]       │
     │   file3]         │<────────────────────│
     │<─────────────────│                     │
     │                  │                     │
     │  [agent compares to inventory]         │
     │  [file3 is unmanaged - offer import]   │
```

## List vs Read

* **List** returns resource identifiers (native IDs)
* **Read** is then called for each ID to get full properties

This two-phase approach allows efficient discovery. List is cheap (just IDs), and Read is only called when needed.

## ListRequest

The agent sends a `ListRequest` during discovery:

```go theme={"languages":{"custom":["/languages/pkl.json"]}}
type ListRequest struct {
    ResourceType         string            // Which resource type to discover
    TargetConfig         json.RawMessage   // Connection details for the target
    PageSize             int32             // Max results per page (0 = plugin default)
    PageToken            *string           // Resume token from previous page
    AdditionalProperties map[string]string // Plugin-specific discovery parameters
}
```

List returns a `ListResult` containing the native IDs found:

```go theme={"languages":{"custom":["/languages/pkl.json"]}}
type ListResult struct {
    NativeIDs     []string // Infrastructure identifiers
    NextPageToken *string  // Set if more results exist
}
```

If your infrastructure has many resources, implement pagination by setting `NextPageToken`. The agent will call List again with this token until it's nil.

## Test

Add a test for List to `sftp_test.go`:

```go theme={"languages":{"custom":["/languages/pkl.json"]}}
// =============================================================================
// List Tests
// =============================================================================

// TestList verifies that the Plugin.List method:
// 1. Returns all file paths in the directory
// 2. Can be used for discovery of unmanaged resources
//
// Setup: Create multiple files using asyncsftp
// Execute: List using Plugin
// Verify: Assert returned paths include our files
func TestList(t *testing.T) {
	if os.Getenv("SFTP_USERNAME") == "" || os.Getenv("SFTP_PASSWORD") == "" {
		t.Skip("SFTP_USERNAME and SFTP_PASSWORD must be set")
	}

	ctx := context.Background()

	// --- Setup: Create files using asyncsftp ---
	client, err := asyncsftp.NewClient(testConfig())
	require.NoError(t, err, "failed to create asyncsftp client")
	defer client.Close()

	testFiles := []string{
		"/upload/test-list-1.txt",
		"/upload/test-list-2.txt",
		"/upload/test-list-3.txt",
	}

	for _, filePath := range testFiles {
		opID := client.StartUpload(filePath, "content", 0644)
		require.Eventually(t, func() bool {
			op, _ := client.GetStatus(opID)
			return op != nil && op.State == asyncsftp.StateCompleted
		}, 10*time.Second, 100*time.Millisecond, "file upload should complete")
	}

	// --- Execute: List using Plugin ---
	plugin := &Plugin{}

	req := &resource.ListRequest{
		ResourceType: "SFTP::Files::File",
		TargetConfig: testTargetConfig(),
	}

	result, err := plugin.List(ctx, req)
	require.NoError(t, err, "List should not return error")

	// --- Verify: Assert returned paths include our files ---
	for _, filePath := range testFiles {
		assert.Contains(t, result.NativeIDs, filePath,
			"List should include %s", filePath)
	}

	t.Logf("List returned %d files", len(result.NativeIDs))

	// --- Cleanup ---
	for _, filePath := range testFiles {
		_ = client.StartDelete(filePath)
	}
}
```

### Test pattern

| Step    | Action                        | Tool      |
| ------- | ----------------------------- | --------- |
| Setup   | Create multiple files         | asyncsftp |
| Execute | List files                    | Plugin    |
| Verify  | Assert all files are returned | assert    |

## Run the test to verify it fails

```bash theme={"languages":{"custom":["/languages/pkl.json"]}}
SFTP_USERNAME=testuser SFTP_PASSWORD=testpass go test -tags=integration -run TestList -v
```

The test fails with `not implemented`.

## Implementation

```go theme={"languages":{"custom":["/languages/pkl.json"]}}
// List returns all resource identifiers of a given type.
// Called during discovery to find unmanaged resources.
func (p *Plugin) List(ctx context.Context, req *resource.ListRequest) (*resource.ListResult, error) {
	// Get SFTP client
	client, err := p.getClient(req.TargetConfig)
	if err != nil {
		return &resource.ListResult{
			NativeIDs: []string{},
		}, nil
	}

	// List files in the upload directory
	// In a real plugin, you might use AdditionalProperties to specify the directory
	dir := "/upload"
	if d, ok := req.AdditionalProperties["directory"]; ok {
		dir = d
	}

	paths, err := client.ListFiles(dir)
	if err != nil {
		// If directory doesn't exist, return empty list
		if errors.Is(err, asyncsftp.ErrNotFound) {
			return &resource.ListResult{
				NativeIDs: []string{},
			}, nil
		}
		return &resource.ListResult{
			NativeIDs: []string{},
		}, nil
	}

	return &resource.ListResult{
		NativeIDs:     paths,
		NextPageToken: nil, // No pagination for this simple implementation
	}, nil
}
```

### Key points

1. **Return native IDs**: just the file paths, not full properties
2. **Handle empty gracefully**: return empty list, not an error
3. **Pagination support**: use `NextPageToken` for large result sets (not needed here)
4. **AdditionalProperties**: can be used for discovery configuration (e.g., which directory to scan)

## Verify

Run the test to confirm List discovers files correctly:

```bash theme={"languages":{"custom":["/languages/pkl.json"]}}
SFTP_USERNAME=testuser SFTP_PASSWORD=testpass go test -tags=integration -run TestList -v
```

The test creates several files, then verifies that List returns all of them:

```text theme={"languages":{"custom":["/languages/pkl.json"]}}
=== RUN   TestList
    sftp_test.go:482: List returned 6 files
--- PASS: TestList (0.72s)
PASS
```

## Discovery flow

Here's how discovery works end-to-end:

1. **Agent calls List** and the plugin returns all native IDs
2. **Agent compares to inventory** and identifies unmanaged resources
3. **Agent calls Read** for each unmanaged resource to get full properties
4. **Agent presents to user** who can import into formae management

## Non-discoverable resources

Some resource types cannot be listed. For example, the infrastructure API might not provide a list endpoint. In these cases, mark the resource as non-discoverable in its schema:

```kotlin theme={"languages":{"custom":["/languages/pkl.json"]}}
@formae.ResourceHint {
    type = "Example::Service::Resource"
    identifier = "$.id"
    discoverable = false
}
```

The agent will skip these resource types during discovery scans. Resources are discoverable by default, so you only need to set this when explicitly disabling discovery.

## Summary

List enables resource discovery:

| Aspect     | Description                               |
| ---------- | ----------------------------------------- |
| Purpose    | Find unmanaged resources                  |
| Returns    | Native IDs only (not full properties)     |
| Pagination | Use `NextPageToken` for large result sets |
| Errors     | Return empty list, not errors             |

With List implemented, your plugin supports the complete discovery workflow.

***

Next: [10 - Error Handling](/plugin-development/tutorial/10-error-handling) - Deep dive into OperationErrorCode patterns
